This commit is contained in:
2026-07-06 16:21:06 +08:00
commit bb9f685221
41 changed files with 4333 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Binaries
/lmvpn
/lmvpn.exe
/bin/
/dist/
# Build artifacts
*.app
*.dmg
*.exe
# Go
/vendor/
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Local data (never commit runtime files)
/data/
*.db
*.db-journal
*.log
# Fyne cache
/.fyne/
+73
View File
@@ -0,0 +1,73 @@
# LMVPN Client — Makefile
APP_NAME = LMVPN
BUNDLE_ID = com.lmvpn.client
BINARY = lmvpn
BUILD_DIR = build
APP_BUNDLE = $(APP_NAME).app
GO = go
CGO_ENABLED = 1
LDFLAGS = -s -w
.PHONY: all build app run daemon clean vet tidy fmt
## all: build the .app bundle (default)
all: app
## build: compile the binary
build:
mkdir -p $(BUILD_DIR)
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) .
## app: build binary and assemble .app bundle
app: build
rm -rf $(APP_BUNDLE)
mkdir -p $(APP_BUNDLE)/Contents/MacOS
mkdir -p $(APP_BUNDLE)/Contents/Resources
cp $(BUILD_DIR)/$(BINARY) $(APP_BUNDLE)/Contents/MacOS/$(BINARY)
cp resources/Info.plist $(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
@echo "Built $(APP_BUNDLE)"
## icon: generate icon.icns from resources/icon.png
icon:
@if [ ! -f resources/icon.png ]; then echo "resources/icon.png not found"; exit 1; fi
mkdir -p resources/icon.iconset
@for size in 16 32 64 128 256 512 1024; do \
sips -z $$size $$size resources/icon.png --out resources/icon.iconset/icon_$${size}x$${size}.png >/dev/null 2>&1; \
done
@for pair in "16 32" "32 64" "128 256" "256 512"; do \
set -- $$pair; \
sips -z $$2 $$2 resources/icon.png --out resources/icon.iconset/icon_$${1}x$${1}@2x.png >/dev/null 2>&1; \
done
cp resources/icon.png resources/icon.iconset/icon_512x512@2x.png
iconutil -c icns resources/icon.iconset -o resources/icon.icns
rm -rf resources/icon.iconset
@echo "Generated resources/icon.icns"
## run: build and run the GUI
run: build
./$(BUILD_DIR)/$(BINARY)
## daemon: run the daemon (needs root)
daemon: build
sudo ./$(BUILD_DIR)/$(BINARY) daemon
## vet: run go vet
vet:
$(GO) vet ./...
## tidy: run go mod tidy
tidy:
$(GO) mod tidy
## fmt: format Go code
fmt:
$(GO) fmt ./...
## clean: remove build artifacts
clean:
rm -rf $(BUILD_DIR) $(APP_BUNDLE)
Executable
BIN
View File
Binary file not shown.
+759
View File
@@ -0,0 +1,759 @@
# LMVPN 客户端开发文档
> 本文档面向 LMVPN 客户端开发者,描述客户端与服务端之间的通信协议规范。
>
> **数据来源**:本文档所有字段、阈值、行为描述均严格对应 `lmvpn_server` 的 Go 源码实现,标注格式为 `文件:行号`,便于核对。本文档不依赖任何测试脚本或外部示例。
>
> **定位**:纯协议规范,平台无关,不含完整客户端实现代码。客户端开发者可据此在任意语言/平台实现兼容的客户端。
---
## 目录
1. [概述与架构](#1-概述与架构)
2. [术语与消息分类](#2-术语与消息分类)
3. [传输层规范](#3-传输层规范)
4. [认证协议](#4-认证协议)
5. [隧道握手协议](#5-隧道握手协议)
6. [数据平面](#6-数据平面)
7. [心跳与保活](#7-心跳与保活)
8. [限制与配额](#8-限制与配额)
9. [错误码与控制消息](#9-错误码与控制消息)
10. [消息格式速查表](#10-消息格式速查表)
11. [TUN 接口配置说明](#11-tun-接口配置说明)
12. [服务端配套依赖](#12-服务端配套依赖)
13. [典型问题排查](#13-典型问题排查)
14. [附录 A:关键常量表](#附录-a关键常量表)
15. [附录 B:源码文件索引](#附录-b源码文件索引)
---
## 1. 概述与架构
### 1.1 什么是 LMVPN
LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的 VPN 系统。服务端通过 WebSocket 与客户端建立控制通道,TUN 网卡与 WebSocket 之间双向搬运原始 IP 数据包,实现三层(网络层)VPN 隧道。
### 1.2 架构拓扑
```
┌─────────────┐ WebSocket (ws/wss) ┌─────────────┐
│ 客户端 │ ◄─────────────────────────────────► │ 服务端 │
│ │ 控制消息: 文本帧 (JSON) │ │
│ TUN 网卡 │ 数据消息: 二进制帧 (IP 包) │ TUN 网卡 │
│ (utun/tun) │ │ (tun0/...) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 应用层流量 │ 物理网卡
▼ ▼
应用程序 公网 / 局域网
```
### 1.3 工作原理
1. 客户端通过 WebSocket 连接服务端 `/ws` 端点
2. 完成身份认证(JWT 或用户名/密码)
3. 服务端为客户端分配 VPN 内网 IP,发送 `init` 消息
4. 客户端据此配置本地 TUN 网卡,回复 `ready`
5. 此后双方通过二进制 WebSocket 帧互传 IP 数据包:
- 客户端 TUN 读到的包 → WebSocket 二进制帧 → 服务端 TUN 写入(出网)或转发给其他客户端
- 服务端 TUN 读到的包 → WebSocket 二进制帧 → 客户端 TUN 写入(下行流量)
### 1.4 角色定义
| 角色 | 说明 |
|------|------|
| **Server** | LMVPN 服务端,监听 WebSocket,管理 TUN 网卡、IP 分配、包转发 |
| **Client** | LMVPN 客户端,连接服务端,维护本地 TUN 网卡,收发 IP 包 |
| **TUN** | 虚拟网卡,Linux 为 `tunN`macOS 为 `utunN`Windows 为 wintun |
---
## 2. 术语与消息分类
WebSocket 连接上的消息分为两类:
### 2.1 文本消息(Text Message
UTF-8 编码的 JSON 文本,用于**控制面**:认证、握手、错误通知。所有控制消息均含 `type` 字段标识类型。
涉及的 `type` 取值:`auth``auth_ok``auth_err``init``ready``error`
### 2.2 二进制消息(Binary Message
原始 IP 数据包(IPv4 或 IPv6),用于**数据面**。二进制帧**不是** JSON,不包含任何包装/封装头,就是裸 IP 包。
> ⚠️ 客户端不应向服务端发送既非合法 JSON 控制消息、也非合法 IP 包的二进制数据——服务端会解析失败并丢弃,但不会断开连接(`internal/vpn/tunnel.go:170-185`)。
---
## 3. 传输层规范
### 3.1 WebSocket 端点
| 项目 | 值 | 源码 |
|------|----|----|
| 路径 | `GET /ws` | `internal/router/router.go:15` |
| 协议 | WebSocketRFC 6455 | `internal/vpn/handler.go:32-39` |
| 子协议 | 无 | — |
| 读缓冲 | 4096 字节 | `internal/vpn/handler.go:17` |
| 写缓冲 | 4096 字节 | `internal/vpn/handler.go:18` |
### 3.2 Origin 校验
服务端对 WebSocket 升级请求的 `Origin` 头做如下校验(`internal/vpn/handler.go:19-29`):
- `Origin` 头**为空**:**放行**(非浏览器客户端场景)
- `Origin` 头**非空**:解析其 host 部分,**必须等于**请求本身的 host,否则拒绝升级
> 客户端实现建议:非浏览器环境可不发送 `Origin` 头,或发送与目标 host 一致的 Origin,避免被拒。
### 3.3 WSS / 反向代理
生产环境服务端通常仅监听 HTTP 或 Unix Socket`main.go:47-83`),由反向代理(如 Caddy/Nginx)提供 HTTPS/WSS 终结。客户端连接时应使用 `wss://` 协议并连接反向代理地址。
> 服务端默认监听 TCP `:8080` 与 Unix Socket `/run/lmvpnweb.sock``internal/config/config.go:32-46`),实际地址以部署为准。
---
## 4. 认证协议
客户端必须在 WebSocket 连接建立后完成认证,否则无法进入隧道握手阶段。认证方式二选一。
### 4.1 方式 AJWTquery 参数)
#### 4.1.1 获取 JWT
通过 HTTP 登录接口获取:
| 项目 | 值 | 源码 |
|------|----|----|
| 方法 | `POST` | `internal/router/router.go:17` |
| 路径 | `/api/login` | 同上 |
| 限流 | 5 次/分钟·IP(超出返回 `429` | `internal/middleware/ratelimit.go:75-86` |
**请求体**JSON):
```json
{ "username": "alice", "password": "secret" }
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `username` | string | 是 | 用户名 |
| `password` | string | 是 | 明文密码 |
**成功响应**`200``internal/handler/auth.go:21-30,74-82`):
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": 2, "username": "alice", "role": "user" }
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `token` | string | JWT,用于 WebSocket 认证 |
| `user.id` | uint | 用户 ID |
| `user.username` | string | 用户名 |
| `user.role` | string | 角色(`user` / `admin` |
**失败响应**
| HTTP 状态 | 场景 | 响应体 | 源码 |
|-----------|------|--------|------|
| `400` | 参数缺失 | `{"error":"请输入用户名和密码"}` | `auth.go:34-37` |
| `401` | 用户名不存在 / 密码错误 | `{"error":"用户名或密码错误"}` | `auth.go:40-48` |
| `403` | 账号已禁用(`status != 1` | `{"error":"账号已被禁用"}` | `auth.go:50-53` |
| `429` | 限流 | `{"error":"请求过于频繁,请稍后再试"}` | `ratelimit.go:79-81` |
| `500` | 生成令牌/会话失败 | `{"error":"生成令牌失败"}` 等 | `auth.go:57-72` |
#### 4.1.2 JWT 规格
| 项目 | 值 | 源码 |
|------|----|----|
| 签名算法 | HS256 | `internal/middleware/auth.go:43` |
| 有效期 | 24 小时 | `internal/middleware/auth.go:15,39` |
| 密钥来源 | 服务端配置(见 §12) | `internal/config/config.go:84-98` |
**Claims 字段**`internal/middleware/auth.go:23-29`):
| 字段 | 类型 | 说明 |
|------|------|------|
| `session_id` | string | 会话 IDUUID |
| `user_id` | uint | 用户 ID |
| `username` | string | 用户名 |
| `role` | string | 角色 |
| `exp` | numeric date | 过期时间(标准 JWT claim |
| `iat` | numeric date | 签发时间(标准 JWT claim |
#### 4.1.3 使用 JWT 连接
将 JWT 作为 query 参数附加到 WebSocket URL
```
ws://host:port/ws?token=<JWT>
wss://host/ws?token=<JWT>
```
服务端处理逻辑(`internal/vpn/handler.go:33,41-56`):
-`token` 参数非空,解析并验证 JWT
- 验证失败 → 发送 `{"type":"auth_err","message":"令牌无效或已过期"}` 并关闭连接
- 用户不存在或 `status != 1` → 发送 `{"type":"auth_err","message":"用户不存在或已禁用"}` 并关闭连接
- 验证通过 → **直接进入隧道握手**(无需再发送 `auth` 消息)
> ℹ️ **JWT 路径不触发密码认证限流**。密码认证限流仅作用于方式 B。
### 4.2 方式 B:用户名/密码(首条消息)
连接 WebSocket 后(不带 `token` 参数),客户端须**立即**发送一条文本消息完成认证。
#### 4.2.1 认证消息
```json
{ "type": "auth", "username": "alice", "password": "secret" }
```
| 字段 | 类型 | 必填 | 说明 | 源码 |
|------|------|------|------|------|
| `type` | string | 是 | 固定值 `"auth"` | `internal/vpn/auth.go:17-21,35` |
| `username` | string | 是 | 用户名 | 同上 |
| `password` | string | 是 | 明文密码 | 同上 |
> ⚠️ 这必须是连接建立后客户端发送的**第一条**消息。服务端读取的第一条消息若不是合法的 `auth` JSON,连接将被关闭(`internal/vpn/auth.go:29-39`)。
#### 4.2.2 认证响应
| 响应 | 含义 | 源码 |
|------|------|------|
| `{"type":"auth_ok"}` | 认证成功,进入隧道握手 | `internal/vpn/auth.go:61-65` |
| `{"type":"auth_err","message":"..."}` | 认证失败,随后服务端关闭连接 | `internal/vpn/auth.go:36,43,50,56` |
`auth_err``message` 可能取值见 [§9.2](#92-auth_err-文案表)。
#### 4.2.3 密码认证限流
| 项目 | 值 | 源码 |
|------|----|----|
| 限流键 | `客户端IP + ":" + 用户名` | `internal/vpn/auth.go:41` |
| 阈值 | 5 次/分钟 | `internal/vpn/auth.go:15` |
| 触发后行为 | 发送 `auth_err` "认证尝试过于频繁,请稍后再试" 并关闭连接 | `internal/vpn/auth.go:42-46` |
> 注意:限流在**读取并解析**到合法 `auth` 消息后才检查。若首条消息不是合法 JSON 或 `type != "auth"`,直接关闭连接,不消耗限流配额(`internal/vpn/auth.go:35-39`)。
### 4.3 用户状态要求
无论哪种认证方式,用户必须满足 `status = 1`(启用状态)。被禁用的用户(`status != 1`)无法认证通过(`internal/vpn/auth.go:49``internal/vpn/handler.go:49`)。
### 4.4 认证流程图
```mermaid
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: WebSocket 升级请求 GET /ws[?token=JWT]
S-->>C: 升级成功 (101)
alt 方式 A: JWT
S->>S: 解析验证 JWT
alt 验证失败/用户禁用
S-->>C: {"type":"auth_err","message":"..."}
S--xC: 关闭连接
else 验证通过
S->>S: 进入隧道握手
end
else 方式 B: 用户名/密码
C->>S: 文本: {"type":"auth","username":"","password":""}
S->>S: 检查限流 / 校验凭据
alt 失败
S-->>C: {"type":"auth_err","message":"..."}
S--xC: 关闭连接
else 成功
S-->>C: {"type":"auth_ok"}
S->>S: 进入隧道握手
end
end
```
---
## 5. 隧道握手协议
认证通过后,服务端进入 `runTunnel` 流程(`internal/vpn/tunnel.go:83`),执行前置检查、IP 分配、发送 `init`,并等待客户端 `ready`
### 5.1 前置检查
服务端在分配 IP 前依次检查,任一失败则发送 `error` 消息并**关闭连接**(不进入握手):
| 检查项 | 失败消息 | 源码 |
|--------|----------|------|
| VPN 服务已启用(`VPN.Running()` | `{"type":"error","message":"VPN 服务未启用"}` | `internal/vpn/tunnel.go:86-89` |
| 该用户连接数 < 3 | `{"type":"error","message":"连接数已达上限"}` | `internal/vpn/tunnel.go:91-97` |
| IP 分配成功 | `{"type":"error","message":"分配 IP 失败: <原因>"}` | `internal/vpn/tunnel.go:109-113` |
### 5.2 IP 分配规则
服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66``internal/vpn/service.go:47-57`):
| 地址 | 分配规则 | 说明 |
|------|----------|------|
| 子网第 1 个主机地址 | **服务器 IP**(固定) | `cidr.Host(ipNet, 1)` |
| 子网第 2 个主机地址起 | **动态分配**给客户端 | 首次可用地址 |
| 预留地址 | 按 userID 绑定的固定 IP | 优先于动态分配 |
- 子网第 0 个地址为网络地址,保留不分配
- 子网最后地址为广播地址,保留不分配
- 故可用容量 = `AddressCount - 3``internal/vpn/alloc.go:106-112`
- 若该用户有预留 IP,优先使用预留;预留被占用时报错(`alloc.go:43-48`
- 地址耗尽时报错 "可用 IP 地址已耗尽"`alloc.go:65`
### 5.3 init 消息
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:126-137`):
```json
{
"type": "init",
"ip": "10.0.0.5",
"prefix": 24,
"mtu": 1420,
"server_ip": "10.0.0.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` |
### 5.4 ready 消息与超时
客户端收到 `init` 后,应:
1. 创建并配置本地 TUN 网卡(地址=`ip/prefix`MTU=`mtu`,详见 [§11](#11-tun-接口配置说明)
2. 配置完成后,**立即**发送 `ready` 文本消息:
```json
{ "type": "ready" }
```
**超时约束**`internal/vpn/tunnel.go:142-143,187-194`):
- 服务端发送 `init` 后将读超时设为 **30 秒**`readyTimeout`
- 客户端必须在此 30 秒内发送 `ready`
- 超时未收到 `ready`,服务端**关闭连接**(日志 "等待 ready 超时"
-`ready` 之前发送的二进制数据帧将被**丢弃**(不进入 TUN)
### 5.5 握手时序图
```mermaid
sequenceDiagram
participant C as 客户端
participant S as 服务端
Note over C,S: 认证已完成
S->>S: 前置检查 (VPN启用/连接数/IP分配)
alt 检查失败
S-->>C: {"type":"error","message":"..."}
S--xC: 关闭连接
else 检查通过
S-->>C: {"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"..."}
C->>C: 创建并配置 TUN 网卡
C-->>S: {"type":"ready"}
S->>S: 标记 ready=true, 读超时重置为60s
Note over C,S: 数据面就绪,开始双向收发 IP 包
end
```
---
## 6. 数据平面
`ready` 之后,连接进入数据传输阶段。双方通过**二进制 WebSocket 帧**互传 IP 数据包。
### 6.1 二进制帧格式
二进制帧的载荷即**原始 IP 数据包**,无任何额外封装头:
- **IPv4 包**:长度 ≥ 20 字节,源/目的地址位于偏移 12-19(`internal/vpn/switch.go:83-87`
- **IPv6 包**:长度 ≥ 40 字节,源地址偏移 8-23,目的地址偏移 24-39(`internal/vpn/switch.go:88-96`
服务端使用 `waterutil` 库解析 IP 头以获取源/目的地址(`internal/vpn/switch.go:78-99`)。
### 6.2 数据流向
#### 6.2.1 客户端 → 服务端(上行)
客户端从本地 TUN 网卡读取 IP 包,作为二进制帧发送。服务端收到后(`internal/vpn/tunnel.go:196-207`):
1. 统计接收字节数
2. 调用 `RouteFromClient` 判断转发目标(见 §6.3
3. 若无客户端目标 → 写入服务端 TUN(经服务器出网)
4. 若有客户端目标 → 转发给目标客户端
#### 6.2.2 服务端 → 客户端(下行)
服务端从 TUN 网卡读取 IP 包,根据目的 IP 查找已连接客户端,作为二进制帧发送给对应客户端(`internal/vpn/service.go:111-135``internal/vpn/switch.go:129-140`)。客户端收到后写入本地 TUN。
### 6.3 转发路由判定
服务端 `PacketSwitch` 对客户端发来的包做如下判定(`internal/vpn/switch.go:108-127`):
| 目的地址类型 | `allow_client_to_client=true` | `allow_client_to_client=false` |
|--------------|-------------------------------|--------------------------------|
| 单播 + 目的为已连接客户端 | 转发给该客户端 | 写服务器 TUN(出网) |
| 单播 + 目的非客户端 | 写服务器 TUN(出网) | 写服务器 TUN(出网) |
| 非单播(广播/多播) | 转发给所有其他客户端 | 不处理(丢弃) |
> `allow_client_to_client` 是服务端全局开关,由管理员配置(`internal/model/vpn.go:13`)。客户端无法查询此开关,应假设其可能为 `false`。
### 6.4 反欺骗(Anti-Spoofing
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-116`):
```
if 源IP != 该客户端被分配的IP:
丢弃该包(不转发,不写入TUN,不断开连接)
```
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址为 `init.ip` 的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。
### 6.5 非 IP 二进制帧
若二进制帧无法识别为 IPv4 或 IPv6(长度不足或版本字段不符),服务端直接丢弃,不报错(`internal/vpn/switch.go:78-99,109-112`)。
---
## 7. 心跳与保活
### 7.1 服务端 Ping
服务端在 `runTunnel` 启动一个 goroutine,每 **30 秒**发送一个 WebSocket **Ping 控制帧**(非文本消息,是 WebSocket 协议层的 ping)(`internal/vpn/tunnel.go:145-156`):
```go
ticker := time.NewTicker(pingPeriod) // 30s
conn.WriteControl(websocket.PingMessage, nil, ...)
```
### 7.2 客户端 Pong 义务
客户端必须响应 WebSocket Ping 帧回送 Pong。大多数 WebSocket 库会自动处理,但若客户端禁用了自动 pong,则需手动响应。
服务端设置 Pong 处理器(`internal/vpn/tunnel.go:158-161`):每收到 Pong,将读超时重置为 **60 秒**
> 若 60 秒内无任何消息(含 Pong),服务端将因读超时关闭连接。
### 7.3 超时常量
| 常量 | 值 | 含义 | 源码 |
|------|----|----|------|
| `readTimeout` | 60s | ready 后的读超时(收到任意消息或 Pong 后重置) | `internal/vpn/tunnel.go:17` |
| `writeTimeout` | 10s | 单次写操作超时(控制消息/数据帧/Ping) | `internal/vpn/tunnel.go:18` |
| `readyTimeout` | 30s | 等待客户端 `ready` 的超时 | `internal/vpn/tunnel.go:19` |
| `pingPeriod` | 30s | Ping 发送周期 | `internal/vpn/tunnel.go:20` |
### 7.4 客户端保活与重连建议
- **不要禁用** WebSocket 自动 Pong(若库支持)
- 若库不自动处理,需在收到 Ping 时立即回 Pong
- 客户端可不必主动发送 Ping(服务端单向心跳即可)
- 连接断开后建议采用**指数退避重连**(如 1s → 2s → 4s → ...,上限 60s
- 重连后需重新走完整认证 + 握手流程
- 重连后分配的 IP 可能与上次不同(除非配置了 IP 预留)
---
## 8. 限制与配额
### 8.1 消息大小
| 限制 | 值 | 源码 |
|------|----|----|
| 单条 WebSocket 消息最大 | 1 MB`1 << 20` 字节) | `internal/vpn/tunnel.go:21,141` |
> 超过此限制的帧会导致读错误,连接被关闭。考虑 MTU 默认 1420,单 IP 包远小于此限制,正常使用不会触发。
### 8.2 并发连接数
| 限制 | 值 | 源码 |
|------|----|----|
| 单用户最大并发连接 | 3 | `internal/vpn/tunnel.go:22,92-97` |
> 超出时新连接收到 `error` "连接数已达上限" 后被关闭。旧连接不受影响。
### 8.3 子网约束
| 约束 | 值 | 源码 |
|------|----|----|
| IP 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
| 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
| 可用容量 | `AddressCount - 3` | `internal/vpn/alloc.go:106-112` |
### 8.4 MTU 约束
| 约束 | 值 | 源码 |
|------|----|----|
| 范围 | 500 65535 | `internal/handler/vpn.go:130-136` |
| 默认值 | 1420 | `internal/model/vpn.go:11` |
> MTU 由服务端管理员配置,客户端应使用 `init.mtu` 的值,不要自行设定。
---
## 9. 错误码与控制消息
### 9.1 控制消息结构
所有文本控制消息均为 JSON,通用结构(`internal/vpn/protocol.go:11-13`):
```go
type controlMessage struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
}
```
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-9`):
```go
type initMessage struct {
Type string `json:"type"`
IP string `json:"ip"`
Prefix int `json:"prefix"`
MTU int `json:"mtu"`
ServerIP string `json:"server_ip"`
}
```
### 9.2 `auth_err` 文案表
| `message` | 触发条件 | 源码 |
|-----------|----------|------|
| `令牌无效或已过期` | JWT 认证:解析失败/过期 | `internal/vpn/handler.go:44` |
| `用户不存在或已禁用` | JWT 认证:用户不存在或 `status != 1` | `internal/vpn/handler.go:50` |
| `消息格式错误` | 密码认证:首条消息非 JSON 或 `type != "auth"` | `internal/vpn/auth.go:36` |
| `认证尝试过于频繁,请稍后再试` | 密码认证:触发限流(5/min·IP+用户名) | `internal/vpn/auth.go:43` |
| `用户名或密码错误` | 密码认证:用户不存在或密码不符 | `internal/vpn/auth.go:50,56` |
> 所有 `auth_err` 之后服务端都会**关闭连接**。
### 9.3 `error` 文案表
`error` 消息在隧道握手阶段(认证后、`init` 前或 `init` 后)发送,随后**关闭连接**
| `message` | 触发条件 | 源码 |
|-----------|----------|------|
| `VPN 服务未启用` | `VPN.Running() == false` | `internal/vpn/tunnel.go:88` |
| `连接数已达上限` | 该用户已有 3 个活跃连接 | `internal/vpn/tunnel.go:95` |
| `分配 IP 失败: <原因>` | IP 分配失败(如耗尽、预留冲突) | `internal/vpn/tunnel.go:112` |
### 9.4 客户端对错误消息的处理建议
- 收到 `auth_err`:认证失败,连接将被关闭。应提示用户检查凭据,避免频繁重试触发限流
- 收到 `error`:服务端拒绝建立隧道。根据 `message` 区分原因:
- "VPN 服务未启用" → 联系管理员
- "连接数已达上限" → 等待其他连接断开或联系管理员
- "分配 IP 失败" → 地址池耗尽,联系管理员
- 连接被服务端主动关闭时,客户端不应立即重连,应遵守退避策略
---
## 10. 消息格式速查表
### 10.1 客户端 → 服务端
| 消息 | WebSocket 类型 | 时机 | 载荷 | 示例 |
|------|----------------|------|------|------|
| `auth` | Text | 连接后立即(仅方式 B | JSON | `{"type":"auth","username":"...","password":"..."}` |
| `ready` | Text | 收到 `init` 且 TUN 配置完成后 | JSON | `{"type":"ready"}` |
| 数据帧 | Binary | `ready` 之后 | 原始 IP 包 | (二进制) |
| Pong | WebSocket Pong | 收到服务端 Ping 时 | 空 | (由 WebSocket 库处理) |
### 10.2 服务端 → 客户端
| 消息 | WebSocket 类型 | 时机 | 载荷 | 示例 |
|------|----------------|------|------|------|
| `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":"..."}` |
| `error` | Text | 握手阶段失败 | JSON | `{"type":"error","message":"..."}` |
| 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) |
| Ping | WebSocket Ping | 每 30 秒 | 空 | WebSocket 协议层) |
---
## 11. TUN 接口配置说明
客户端收到 `init` 后须自行创建并配置 TUN 网卡。本节给出各平台的配置规范(仅规范,不含完整代码)。
### 11.1 通用配置项
| 配置项 | 取值来源 | 说明 |
|--------|----------|------|
| TUN 网卡地址 | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` |
| MTU | `init.mtu` | 如 `1420` |
| 对端地址 / 默认路由网关 | `init.server_ip` | 如 `10.0.0.1` |
### 11.2 Linux
参考服务端实现(`internal/vpn/tun_linux.go`):
```
# 创建 TUN(通常由库完成,如 songgao/water
ip link set dev <tun> up
ip addr add dev <tun> <ip>/<prefix> peer <server_ip>
ip link set dev <tun> mtu <mtu>
```
路由:若需将所有流量经 VPN,添加默认路由:
```
ip route add 0.0.0.0/0 dev <tun>
```
### 11.3 macOS
参考服务端实现(`internal/vpn/tun_darwin.go`)。macOS 的 TUN 设备名为 `utunN`,使用 `ifconfig`
```
ifconfig <utun> inet <ip>/<prefix> <server_ip> up
ifconfig <utun> mtu <mtu>
```
路由:
```
route add -inet -net 0.0.0.0/0 -interface <utun>
```
> macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。
### 11.4 Windows
Windows 无原生 TUN,需使用 **wintun** 驱动。客户端须:
1. 加载 wintun.dll
2. 创建 wintun 适配器
3. 设置 IP 地址、前缀、MTU
4. 配置路由
具体 API 参考 wintun 官方文档,本文档不展开。
### 11.5 关于 `DoRemoteIPConfig`
服务端配置中有 `DoRemoteIPConfig` 字段(`internal/model/vpn.go:15`),原意为"由服务端远程配置客户端 TUN"。但当前隧道实现中**未使用**该字段——客户端始终需要根据 `init` 消息**自行配置** TUN 网卡。客户端开发者不应假设服务端会代为配置。
### 11.6 权限要求
创建和配置 TUN 网卡需要提升权限:
| 平台 | 权限 |
|------|------|
| Linux | root 或 `CAP_NET_ADMIN` 能力 |
| macOS | rootsudo |
| Windows | 管理员权限 |
---
## 12. 服务端配套依赖
客户端能否通过 VPN 上网,取决于服务端的网络配置。客户端开发者应了解以下服务端前提(用于排查问题)。
### 12.1 IP 转发
Linux 服务端须开启 IP 转发(`internal/vpn/diag_linux.go:53-60`):
```
sysctl -w net.ipv4.ip_forward=1
```
### 12.2 NAT 伪装
服务端须配置 NAT masquerade,将客户端源 IP 转换为服务器物理网卡 IP(`internal/vpn/diag_linux.go:80-113`)。
**nft(推荐)**
```
nft add table ip nat
nft add chain ip nat postrouting { type nat hook postrouting priority 100 \; }
nft add rule ip nat postrouting oifname <物理网卡> masquerade
```
**iptables(回退)**
```
iptables -t nat -A POSTROUTING -o <物理网卡> -j MASQUERADE
```
> 服务端诊断接口 `GET /api/admin/vpn/diag``internal/handler/vpn.go:190-192`)会检测上述配置,客户端无法上网时可请管理员查看诊断结果。
### 12.3 子网与 MTU
由管理员通过 `PUT /api/admin/vpn/settings` 配置(`internal/handler/vpn.go:110-163`),客户端通过 `init` 消息获取,无需也无法自行指定。
---
## 13. 典型问题排查
| 现象 | 可能原因 | 排查建议 |
|------|----------|----------|
| 连接后立即收到 `auth_err` | 凭据错误 / JWT 过期 / 用户被禁用 | 检查用户名密码;重新登录获取 JWT;联系管理员启用账号 |
| 认证频繁失败 | 触发限流(5/min) | 等待 1 分钟后重试 |
| 收到 `error` "VPN 服务未启用" | 管理员未启用 VPN | 联系管理员 |
| 收到 `error` "连接数已达上限" | 该用户已有 3 个连接 | 关闭其他连接或联系管理员 |
| 收到 `error` "分配 IP 失败" | 地址池耗尽 | 联系管理员扩大子网 |
| 收到 `init` 后 30s 断开 | 未在超时内发送 `ready` | 检查 TUN 配置是否成功;确保配置后立即发 `ready` |
| `ready` 后能连接但无法上网 | 服务端未开 ip_forward / NAT;或客户端未配路由 | 请管理员检查 `GET /api/admin/vpn/diag`;检查客户端默认路由 |
| `ready` 后能连网但部分应用不通 | MTU 过大导致分片丢弃 | 确认 TUN MTU 与 `init.mtu` 一致 |
| 上行流量被静默丢弃 | 源 IP 与分配 IP 不匹配(反欺骗) | 确认 TUN 网卡地址为 `init.ip`,无其他地址干扰 |
| 连接频繁断开 | 60s 内无消息(未响应 Ping | 确保未禁用 WebSocket 自动 Pong |
| 连接被意外关闭 | 单条消息超过 1MB | 检查是否有异常大的非 IP 包被发送 |
---
## 附录 A:关键常量表
| 常量 | 值 | 含义 | 源码位置 |
|------|----|----|---------|
| `readTimeout` | 60s | ready 后读超时 | `internal/vpn/tunnel.go:17` |
| `writeTimeout` | 10s | 写操作超时 | `internal/vpn/tunnel.go:18` |
| `readyTimeout` | 30s | 等待 ready 超时 | `internal/vpn/tunnel.go:19` |
| `pingPeriod` | 30s | Ping 周期 | `internal/vpn/tunnel.go:20` |
| `maxMessageSize` | 1 MB | 单消息上限 | `internal/vpn/tunnel.go:21` |
| `maxConnsPerUser` | 3 | 单用户并发连接上限 | `internal/vpn/tunnel.go:22` |
| `tokenExpire` | 24h | JWT 有效期 | `internal/middleware/auth.go:15` |
| 登录限流 | 5/min·IP | `/api/login` 限流 | `internal/middleware/ratelimit.go:76` |
| 密码认证限流 | 5/min·(IP+用户名) | WebSocket 密码认证限流 | `internal/vpn/auth.go:15,41` |
| MTU 范围 | 50065535 | 配置校验范围 | `internal/handler/vpn.go:131` |
| MTU 默认 | 1420 | 数据库默认值 | `internal/model/vpn.go:11` |
| 子网前缀上限 | /30 | 配置校验 | `internal/handler/vpn.go:75` |
| WebSocket 读缓冲 | 4096 B | — | `internal/vpn/handler.go:17` |
| WebSocket 写缓冲 | 4096 B | — | `internal/vpn/handler.go:18` |
---
## 附录 B:源码文件索引
客户端开发者重点阅读的服务端源码文件:
| 文件 | 内容 |
|------|------|
| `internal/vpn/handler.go` | WebSocket 升级、认证入口、JWT 处理 |
| `internal/vpn/auth.go` | 密码认证逻辑、限流 |
| `internal/vpn/tunnel.go` | 隧道主循环、握手、心跳、数据收发 |
| `internal/vpn/protocol.go` | 控制消息结构定义(`init`/`controlMessage` |
| `internal/vpn/switch.go` | 包转发路由判定、反欺骗、C2C 开关 |
| `internal/vpn/alloc.go` | IP 分配与预留逻辑 |
| `internal/vpn/service.go` | VPN 服务管理、TUN 读写循环 |
| `internal/vpn/tun_linux.go` | Linux TUN 配置(客户端配置可参考) |
| `internal/vpn/tun_darwin.go` | macOS TUN 配置(客户端配置可参考) |
| `internal/middleware/auth.go` | JWT 生成与解析、Claims 结构 |
| `internal/handler/auth.go` | 登录接口 `POST /api/login` |
| `internal/handler/vpn.go` | VPN 设置/状态/预留管理 API |
| `internal/router/router.go` | 路由表(`/ws``/api/*` |
| `internal/model/vpn.go` | VPN 设置数据模型 |
| `internal/model/user.go` | 用户数据模型 |
| `internal/config/config.go` | 服务端配置结构 |
+64
View File
@@ -0,0 +1,64 @@
module lmvpn
go 1.26
require (
github.com/gorilla/websocket v1.5.3
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.1
)
require (
fyne.io/fyne/v2 v2.7.4
fyne.io/systray v1.12.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
github.com/fyne-io/glfw-js v0.3.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.1.0 // indirect
github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/keybase/go-keychain v0.0.1
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
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+137
View File
@@ -0,0 +1,137 @@
fyne.io/fyne/v2 v2.7.4 h1:OVCI5mT+Onb2kA4wlmGA5pLCqKik9f4NDb5jiR1OMTc=
fyne.io/fyne/v2 v2.7.4/go.mod h1:ZD1mmhBY75mSa97IXl3MPlICd1uNHfCXYh5hKIlVOII=
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/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=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
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/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=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
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=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+113
View File
@@ -0,0 +1,113 @@
// Package auth implements the HTTP login flow (POST /api/login) to
// obtain a JWT for WebSocket authentication.
//
// (server: internal/handler/auth.go:32-82, internal/router/router.go:17)
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// LoginResult holds the response from a successful /api/login call.
type LoginResult struct {
Token string `json:"token"`
User LoginUser `json:"user"`
}
// LoginUser is the user object embedded in the login response.
type LoginUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type errorResponse struct {
Error string `json:"error"`
}
// Login performs an HTTP POST to /api/login and returns the JWT.
//
// 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) {
body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil {
return nil, err
}
url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("login request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read login response: %w", err)
}
switch resp.StatusCode {
case http.StatusOK:
var result LoginResult
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("parse login response: %w", err)
}
return &result, nil
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusTooManyRequests, http.StatusInternalServerError:
var e errorResponse
_ = json.Unmarshal(raw, &e)
return nil, &LoginError{Code: resp.StatusCode, Message: e.Error}
default:
return nil, &LoginError{Code: resp.StatusCode, Message: string(raw)}
}
}
// LoginError carries the HTTP status code and server error message.
type LoginError struct {
Code int
Message string
}
func (e *LoginError) Error() string {
return fmt.Sprintf("login failed (%d): %s", e.Code, e.Message)
}
// IsRateLimited reports whether the error is a 429 rate-limit response.
func (e *LoginError) IsRateLimited() bool { return e.Code == http.StatusTooManyRequests }
// WSURLToHTTP converts a WebSocket URL to its HTTP origin.
//
// ws://host:port/ws → http://host:port
// wss://host/ws → https://host
// ws://host:8080 → http://host:8080
func WSURLToHTTP(wsURL string) (string, error) {
u := wsURL
switch {
case strings.HasPrefix(u, "wss://"):
u = "https://" + u[len("wss://"):]
case strings.HasPrefix(u, "ws://"):
u = "http://" + u[len("ws://"):]
default:
return "", fmt.Errorf("invalid WebSocket URL: %s", wsURL)
}
// Strip the path (e.g. /ws) to get just the origin.
if idx := strings.IndexByte(u, '/'); idx > 8 { // keep "https://"
u = u[:idx]
}
return u, nil
}
+57
View File
@@ -0,0 +1,57 @@
// Package config manages the application-level configuration file
// (config.yml), distinct from per-profile server settings stored in
// the database.
package config
import (
"os"
"lmvpn/internal/paths"
"gopkg.in/yaml.v3"
)
// AppConfig holds application-wide settings.
type AppConfig struct {
AutoConnect bool `yaml:"auto_connect"`
MinimizeToTray bool `yaml:"minimize_to_tray"`
CloseToTray bool `yaml:"close_to_tray"`
DefaultProfileID int64 `yaml:"default_profile_id"`
LogLevel string `yaml:"log_level"` // debug, info, warn, error
}
// Default returns the default configuration.
func Default() AppConfig {
return AppConfig{
AutoConnect: false,
MinimizeToTray: true,
CloseToTray: true,
DefaultProfileID: 0,
LogLevel: "info",
}
}
// Load reads the config file, returning defaults if it does not exist.
func Load() (AppConfig, error) {
cfg := Default()
data, err := os.ReadFile(paths.ConfigPath())
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Default(), err
}
return cfg, nil
}
// Save writes the config file with 0600 permissions.
func Save(cfg AppConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(paths.ConfigPath(), data, 0o600)
}
+134
View File
@@ -0,0 +1,134 @@
// Package daemon implements the privileged daemon process that owns
// the WebSocket transport, TUN device, and routing. It receives
// commands from the GUI over an IPC unix socket and broadcasts
// state/stats events back.
//
// The daemon is launched (as root) by the GUI via osascript. It holds
// no persistent state — all configuration is provided by the GUI in
// the Start command.
package daemon
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"lmvpn/internal/ipc"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/paths"
"lmvpn/internal/stats"
"lmvpn/internal/vpn"
)
// Run starts the daemon and blocks until Shutdown is received or a
// signal (SIGINT/SIGTERM) is delivered.
func Run() error {
log.Init(log.RoleDaemon, paths.DaemonLogFile())
log.L().Info("lmvpn daemon starting")
server, err := ipc.NewServer()
if err != nil {
return fmt.Errorf("ipc server: %w", err)
}
defer server.Close()
d := &daemon{server: server}
// Signal handling for clean shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.L().Info("daemon received signal, shutting down")
d.stopSession()
server.Close()
os.Exit(0)
}()
log.L().Info("daemon listening", "socket", paths.IPCSocketPath())
return server.Accept(d.handle)
}
type daemon struct {
server *ipc.Server
session *vpn.SessionManager
cancel context.CancelFunc
}
func (d *daemon) handle(conn net.Conn, req ipc.Request) {
switch req.Cmd {
case ipc.CmdStart:
d.startSession(conn, req)
case ipc.CmdStop:
d.stopSession()
_ = ipc.WriteOK(conn)
case ipc.CmdShutdown:
d.stopSession()
_ = ipc.WriteOK(conn)
d.server.Close()
os.Exit(0)
case ipc.CmdStats:
if d.session != nil {
snap := d.session.Stats().Snapshot()
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
}
_ = ipc.WriteOK(conn)
default:
_ = ipc.WriteErr(conn, "unknown command: "+req.Cmd)
}
}
func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
if req.Config == nil {
_ = ipc.WriteErr(conn, "missing config")
return
}
if d.session != nil {
d.stopSession()
}
cfg := vpn.SessionConfig{
ServerURL: req.Config.ServerURL,
Username: req.Config.Username,
Password: req.Config.Password,
Token: req.Config.Token,
AuthMode: model.AuthMode(req.Config.AuthMode),
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
CustomCIDRs: req.Config.CustomCIDRs,
MTUOverride: req.Config.MTUOverride,
}
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)})
},
func(snap stats.Snapshot) {
s := snap
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
},
)
if err := d.session.Connect(ctx, cfg); err != nil {
_ = ipc.WriteErr(conn, "connect: "+err.Error())
d.session = nil
return
}
_ = ipc.WriteOK(conn)
}
func (d *daemon) stopSession() {
if d.cancel != nil {
d.cancel()
d.cancel = nil
}
if d.session != nil {
d.session.Disconnect()
d.session = nil
}
}
+76
View File
@@ -0,0 +1,76 @@
// Package db manages the SQLite database connection, schema migrations,
// and data access for server profiles and connection logs.
//
// It uses modernc.org/sqlite (a pure-Go driver) so the binary has no
// CGO dependency and cross-compiles trivially.
package db
import (
"database/sql"
"fmt"
"lmvpn/internal/paths"
_ "modernc.org/sqlite"
)
// Store wraps the database handle and provides data access methods.
type Store struct {
db *sql.DB
}
// Open creates or opens the SQLite database and runs migrations.
func Open() (*Store, error) {
db, err := sql.Open("sqlite", paths.DBPath()+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1) // sqlite serialises writes
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(schema)
return err
}
const schema = `
CREATE TABLE IF NOT EXISTS server_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
server_url TEXT NOT NULL,
username TEXT NOT NULL,
auth_mode TEXT NOT NULL DEFAULT 'both',
routing_mode TEXT NOT NULL DEFAULT 'full',
custom_cidrs 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,
last_connected_at DATETIME
);
CREATE TABLE IF NOT EXISTS connection_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at DATETIME,
assigned_ip TEXT NOT NULL DEFAULT '',
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'connected',
error_msg TEXT NOT NULL DEFAULT '',
FOREIGN KEY (profile_id) REFERENCES server_profiles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_logs_profile ON connection_logs(profile_id);
`
+64
View File
@@ -0,0 +1,64 @@
package db
import (
"fmt"
"time"
"lmvpn/internal/model"
)
// StartLog creates a connection log entry and returns its ID.
func (s *Store) StartLog(profileID int64) (int64, error) {
res, err := s.db.Exec(
`INSERT INTO connection_logs (profile_id, started_at, status)
VALUES (?, ?, 'connected')`,
profileID, time.Now())
if err != nil {
return 0, fmt.Errorf("start log: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// 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 {
_, err := s.db.Exec(
`UPDATE connection_logs SET
ended_at = ?, assigned_ip = ?, rx_bytes = ?, tx_bytes = ?,
status = ?, error_msg = ?
WHERE id = ?`,
time.Now(), assignedIP, rxBytes, txBytes, status, errMsg, id)
if err != nil {
return fmt.Errorf("finish log %d: %w", id, err)
}
return nil
}
// 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,
rx_bytes, tx_bytes, status, error_msg
FROM connection_logs WHERE profile_id = ?
ORDER BY started_at DESC LIMIT ?`,
profileID, limit)
if err != nil {
return nil, fmt.Errorf("recent logs: %w", err)
}
defer rows.Close()
var out []model.ConnectionLog
for rows.Next() {
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 {
return nil, err
}
if t, ok := ended.(time.Time); ok {
l.EndedAt = &t
}
out = append(out, l)
}
return out, rows.Err()
}
+108
View File
@@ -0,0 +1,108 @@
package db
import (
"database/sql"
"fmt"
"time"
"lmvpn/internal/model"
)
// CreateProfile inserts a new server profile and returns its ID.
func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
res, err := s.db.Exec(
`INSERT INTO server_profiles
(name, server_url, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect)
VALUES (?,?,?,?,?,?,?,?)`,
p.Name, p.ServerURL, p.Username, p.AuthMode, p.RoutingMode,
p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
)
if err != nil {
return 0, fmt.Errorf("insert profile: %w", err)
}
id, _ := res.LastInsertId()
p.ID = id
p.CreatedAt = time.Now()
return id, nil
}
// GetProfile returns a single profile by ID.
func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
p := &model.ServerProfile{}
var last sql.NullTime
err := s.db.QueryRow(
`SELECT id, name, server_url, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.ServerURL, &p.Username, &p.AuthMode,
&p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect,
&p.CreatedAt, &last)
if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err)
}
if last.Valid {
p.LastConnectedAt = &last.Time
}
return p, nil
}
// ListProfiles returns all saved profiles ordered by name.
func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
rows, err := s.db.Query(
`SELECT id, name, server_url, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
FROM server_profiles ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
defer rows.Close()
var out []model.ServerProfile
for rows.Next() {
var p model.ServerProfile
var last sql.NullTime
if err := rows.Scan(&p.ID, &p.Name, &p.ServerURL, &p.Username,
&p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride,
&p.AutoConnect, &p.CreatedAt, &last); err != nil {
return nil, err
}
if last.Valid {
p.LastConnectedAt = &last.Time
}
out = append(out, p)
}
return out, rows.Err()
}
// UpdateProfile updates an existing profile.
func (s *Store) UpdateProfile(p *model.ServerProfile) error {
_, err := s.db.Exec(
`UPDATE server_profiles SET
name = ?, server_url = ?, username = ?, auth_mode = ?,
routing_mode = ?, custom_cidrs = ?, mtu_override = ?, auto_connect = ?
WHERE id = ?`,
p.Name, p.ServerURL, p.Username, p.AuthMode,
p.RoutingMode, p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID)
if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err)
}
return nil
}
// DeleteProfile removes a profile by ID.
func (s *Store) DeleteProfile(id int64) error {
_, err := s.db.Exec(`DELETE FROM server_profiles WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete profile %d: %w", id, err)
}
return nil
}
// TouchLastConnected records the connection timestamp for a profile.
func (s *Store) TouchLastConnected(id int64) error {
_, err := s.db.Exec(
`UPDATE server_profiles SET last_connected_at = ? WHERE id = ?`,
time.Now(), id)
return err
}
+245
View File
@@ -0,0 +1,245 @@
// Package ipc implements the communication protocol between the GUI
// process (user) and the privileged daemon (root) over a unix domain
// socket.
//
// Protocol: newline-delimited JSON. Each message is one JSON object
// followed by '\n'.
//
// GUI → daemon: Request (start, stop, shutdown, stats)
// daemon → GUI: Event (state, stats, error)
package ipc
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net"
"os"
"sync"
"lmvpn/internal/paths"
"lmvpn/internal/route"
"lmvpn/internal/stats"
)
// Command types sent from GUI to daemon.
const (
CmdStart = "start"
CmdStop = "stop"
CmdShutdown = "shutdown"
CmdStats = "stats"
)
// Event types sent from daemon to GUI.
const (
EvState = "state"
EvStats = "stats"
EvError = "error"
)
// Request is a command from the GUI to the daemon.
type Request struct {
Cmd string `json:"cmd"`
Config *ClientConfig `json:"config,omitempty"`
}
// ClientConfig is the session configuration sent over IPC. It mirrors
// vpn.SessionConfig but is kept separate to avoid importing the vpn
// package (which needs root-only TUN) into the GUI.
type ClientConfig struct {
ServerURL string `json:"server_url"`
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
AuthMode string `json:"auth_mode"`
RoutingMode string `json:"routing_mode"`
CustomCIDRs []string `json:"custom_cidrs"`
MTUOverride int `json:"mtu_override"`
}
// Event is a notification from the daemon to the GUI.
type Event struct {
Event string `json:"event"`
State string `json:"state,omitempty"`
Stats *stats.Snapshot `json:"stats,omitempty"`
Message string `json:"message,omitempty"`
}
// --- Wire helpers ---
func writeMsg(w io.Writer, v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
data = append(data, '\n')
_, err = w.Write(data)
return err
}
func readMsg(r *bufio.Reader, v interface{}) error {
line, err := r.ReadString('\n')
if err != nil {
return err
}
return json.Unmarshal([]byte(line), v)
}
// --- Server (daemon side) ---
// Server listens on the IPC socket and manages connected clients.
type Server struct {
listener net.Listener
mu sync.Mutex
clients map[net.Conn]bool
}
// NewServer creates (but does not start) the IPC server. It removes
// any stale socket file first.
func NewServer() (*Server, error) {
_ = os.Remove(paths.IPCSocketPath())
l, err := net.Listen("unix", paths.IPCSocketPath())
if err != nil {
return nil, fmt.Errorf("listen ipc: %w", err)
}
// Mode 0660 so group members (admin) can connect.
_ = os.Chmod(paths.IPCSocketPath(), 0o660)
return &Server{listener: l, clients: make(map[net.Conn]bool)}, nil
}
// Accept runs the accept loop. Each connection is handled in a
// goroutine; the handler callback receives each Request.
func (s *Server) Accept(handle func(net.Conn, Request)) error {
for {
conn, err := s.listener.Accept()
if err != nil {
return err
}
s.mu.Lock()
s.clients[conn] = true
s.mu.Unlock()
go s.serve(conn, handle)
}
}
func (s *Server) serve(conn net.Conn, handle func(net.Conn, Request)) {
defer func() {
s.mu.Lock()
delete(s.clients, conn)
s.mu.Unlock()
conn.Close()
}()
r := bufio.NewReader(conn)
for {
var req Request
if err := readMsg(r, &req); err != nil {
return
}
handle(conn, req)
}
}
// Broadcast sends an event to all connected clients.
func (s *Server) Broadcast(ev Event) {
data, _ := json.Marshal(ev)
data = append(data, '\n')
s.mu.Lock()
defer s.mu.Unlock()
for c := range s.clients {
_, _ = c.Write(data)
}
}
// Close stops the server and closes all connections.
func (s *Server) Close() {
s.mu.Lock()
defer s.mu.Unlock()
for c := range s.clients {
c.Close()
}
s.clients = nil
if s.listener != nil {
s.listener.Close()
}
}
// --- Client (GUI side) ---
// Client connects to the daemon's IPC socket.
type Client struct {
conn net.Conn
r *bufio.Reader
}
// Dial connects to the daemon.
func Dial() (*Client, error) {
conn, err := net.Dial("unix", paths.IPCSocketPath())
if err != nil {
return nil, fmt.Errorf("dial daemon: %w", err)
}
return &Client{conn: conn, r: bufio.NewReader(conn)}, nil
}
// Send sends a request to the daemon.
func (c *Client) Send(req Request) error {
return writeMsg(c.conn, &req)
}
// Recv reads the next event from the daemon. Blocks until an event is
// available or the connection breaks.
func (c *Client) Recv() (Event, error) {
var ev Event
if err := readMsg(c.r, &ev); err != nil {
return ev, err
}
return ev, nil
}
// Close closes the connection.
func (c *Client) Close() error { return c.conn.Close() }
// Response is a simple ack/error reply to a command.
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// WriteOK sends a success response to a connection.
func WriteOK(conn net.Conn) error {
return writeMsg(conn, Response{OK: true})
}
// WriteErr sends an error response to a connection.
func WriteErr(conn net.Conn, msg string) error {
return writeMsg(conn, Response{OK: false, Error: msg})
}
// SendStart is a convenience helper for sending a start command.
func SendStart(c *Client, cfg ClientConfig) error {
return c.Send(Request{Cmd: CmdStart, Config: &cfg})
}
// SendStop is a convenience helper for sending a stop command.
func SendStop(c *Client) error {
return c.Send(Request{Cmd: CmdStop})
}
// SendShutdown is a convenience helper for sending a shutdown command.
func SendShutdown(c *Client) error {
return c.Send(Request{Cmd: CmdShutdown})
}
// 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
default:
return route.ModeFull
}
}
+31
View File
@@ -0,0 +1,31 @@
// Package keychain stores and retrieves secrets (passwords, JWT tokens)
// in the macOS Keychain. On non-darwin platforms it falls back to an
// in-memory store (to be replaced with a platform-appropriate backend).
//
// Secrets are keyed by the server profile name. The Keychain service
// name is the application bundle identifier.
package keychain
import (
"fmt"
"lmvpn/internal/paths"
)
// ServiceName is the Keychain service under which all lmvpn secrets
// are stored.
const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found")
// Store is the secret storage interface.
type Store interface {
SetPassword(profileName, password string) error
GetPassword(profileName string) (string, error)
DeletePassword(profileName string) error
SetToken(profileName, token string) error
GetToken(profileName string) (string, error)
DeleteToken(profileName string) error
DeleteAll(profileName string) error
}
+92
View File
@@ -0,0 +1,92 @@
//go:build darwin
package keychain
import (
"fmt"
"github.com/keybase/go-keychain"
)
// keyPrefixes distinguish password vs token entries in the keychain.
const (
passwordAccountPrefix = "password:"
tokenAccountPrefix = "token:"
)
// DarwinStore implements Store using the macOS Keychain.
type DarwinStore struct{}
// New returns a macOS Keychain-backed Store.
func New() Store {
return DarwinStore{}
}
func (DarwinStore) setItem(account, secret string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
item.SetData([]byte(secret))
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
// Delete any existing item first (Add fails on duplicates).
_ = keychain.DeleteItem(item)
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("keychain add %s: %w", account, err)
}
return nil
}
func (DarwinStore) getItem(account string) (string, error) {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
item.SetMatchLimit(keychain.MatchLimitOne)
item.SetReturnData(true)
results, err := keychain.QueryItem(item)
if err != nil {
return "", fmt.Errorf("keychain query %s: %w", account, err)
}
if len(results) == 0 {
return "", ErrNotFound
}
return string(results[0].Data), nil
}
func (DarwinStore) deleteItem(account string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
return keychain.DeleteItem(item)
}
func (s DarwinStore) SetPassword(profileName, password string) error {
return s.setItem(passwordAccountPrefix+profileName, password)
}
func (s DarwinStore) GetPassword(profileName string) (string, error) {
return s.getItem(passwordAccountPrefix + profileName)
}
func (s DarwinStore) DeletePassword(profileName string) error {
return s.deleteItem(passwordAccountPrefix + profileName)
}
func (s DarwinStore) SetToken(profileName, token string) error {
return s.setItem(tokenAccountPrefix+profileName, token)
}
func (s DarwinStore) GetToken(profileName string) (string, error) {
return s.getItem(tokenAccountPrefix + profileName)
}
func (s DarwinStore) DeleteToken(profileName string) error {
return s.deleteItem(tokenAccountPrefix + profileName)
}
func (s DarwinStore) DeleteAll(profileName string) error {
_ = s.DeletePassword(profileName)
return s.DeleteToken(profileName)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !darwin
package keychain
// MemStore is an in-memory fallback used on non-darwin platforms.
type MemStore struct {
data map[string]string
}
// New returns an in-memory Store (non-darwin fallback).
func New() Store {
return &MemStore{data: make(map[string]string)}
}
func (m *MemStore) SetPassword(profileName, password string) error {
m.data["password:"+profileName] = password
return nil
}
func (m *MemStore) GetPassword(profileName string) (string, error) {
v, ok := m.data["password:"+profileName]
if !ok {
return "", ErrNotFound
}
return v, nil
}
func (m *MemStore) DeletePassword(profileName string) error {
delete(m.data, "password:"+profileName)
return nil
}
func (m *MemStore) SetToken(profileName, token string) error {
m.data["token:"+profileName] = token
return nil
}
func (m *MemStore) GetToken(profileName string) (string, error) {
v, ok := m.data["token:"+profileName]
if !ok {
return "", ErrNotFound
}
return v, nil
}
func (m *MemStore) DeleteToken(profileName string) error {
delete(m.data, "token:"+profileName)
return nil
}
func (m *MemStore) DeleteAll(profileName string) error {
m.DeletePassword(profileName)
m.DeleteToken(profileName)
return nil
}
+55
View File
@@ -0,0 +1,55 @@
// Package log configures structured logging (slog) to a rotating file
// with a console mirror. Separate log files are used for the GUI and
// the privileged daemon.
package log
import (
"io"
"log/slog"
"os"
"path/filepath"
"gopkg.in/natefinch/lumberjack.v2"
)
// Role selects which log file to write to.
type Role string
const (
RoleGUI Role = "gui"
RoleDaemon Role = "daemon"
)
var logger *slog.Logger
// Init configures the package-level logger writing to the given file
// path (rotated by size) and to stderr.
func Init(role Role, logFile string) *slog.Logger {
if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil {
// Fall back to stderr-only on failure.
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
return logger
}
w := &lumberjack.Logger{
Filename: logFile,
MaxSize: 10, // MB
MaxBackups: 3,
MaxAge: 30, // days
LocalTime: true,
}
mw := io.MultiWriter(os.Stderr, w)
logger = slog.New(slog.NewTextHandler(mw, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
return logger
}
// L returns the package-level logger. It returns a default logger if
// Init was not called.
func L() *slog.Logger {
if logger == nil {
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
return logger
}
+60
View File
@@ -0,0 +1,60 @@
// Package model defines the data structures persisted in SQLite and
// exchanged between application layers.
package model
import "time"
// AuthMode selects how the client authenticates to a server.
type AuthMode string
const (
AuthModeBoth AuthMode = "both" // try JWT, fall back to password
AuthModeJWT AuthMode = "jwt" // HTTP login then ?token=
AuthModePassword AuthMode = "password" // {type:auth} first message
)
// RoutingMode selects which traffic goes through the VPN tunnel.
type RoutingMode string
const (
RoutingFull RoutingMode = "full" // 0.0.0.0/0 via tunnel
RoutingSplit RoutingMode = "split" // only VPN subnet via tunnel
RoutingCustom RoutingMode = "custom" // user-specified CIDRs
)
// ServerProfile is a saved VPN server configuration.
type ServerProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
ServerURL string `json:"server_url"` // e.g. wss://vpn.example.com/ws
Username string `json:"username"`
AuthMode AuthMode `json:"auth_mode"`
RoutingMode RoutingMode `json:"routing_mode"`
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
AutoConnect bool `json:"auto_connect"`
CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"`
}
// ConnectionStatus records the outcome of a connection attempt.
type ConnectionStatus string
const (
StatusConnected ConnectionStatus = "connected"
StatusDisconnected ConnectionStatus = "disconnected"
StatusError ConnectionStatus = "error"
)
// ConnectionLog records a single VPN session.
type ConnectionLog struct {
ID int64 `json:"id"`
ProfileID int64 `json:"profile_id"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
AssignedIP string `json:"assigned_ip"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
Status ConnectionStatus `json:"status"`
ErrorMsg string `json:"error_msg"`
}
+53
View File
@@ -0,0 +1,53 @@
// Package paths resolves platform-specific application directories.
//
// Layout follows each platform's conventions. On macOS:
//
// ~/Library/Application Support/com.lmvpn.client/ user data, db, config
// ~/Library/Caches/com.lmvpn.client/ caches
// ~/Library/Logs/com.lmvpn.client/ logs
package paths
import "os"
// BundleID is the application bundle identifier used as the per-app
// subdirectory name under the platform library folders.
const BundleID = "com.lmvpn.client"
// AppName is the human-readable application name.
const AppName = "LMVPN"
// Dirs describes the resolved application directories.
type Dirs struct {
Data string // persistent user data (db, config)
Cache string // recreatable caches
Log string // log files
}
// Paths is the resolved directory set for the current platform.
var Paths Dirs
// EnsureDirs creates the application directories if they do not exist.
func EnsureDirs() error {
for _, d := range []string{Paths.Data, Paths.Cache, Paths.Log} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
return nil
}
// DBPath returns the path to the SQLite database file.
func DBPath() string { return Paths.Data + "/lmvpn.db" }
// ConfigPath returns the path to the application config file.
func ConfigPath() string { return Paths.Data + "/config.yml" }
// LogFile returns the path to the GUI log file.
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" }
+18
View File
@@ -0,0 +1,18 @@
//go:build darwin
package paths
import (
"os"
"path/filepath"
)
func init() {
home, _ := os.UserHomeDir()
lib := filepath.Join(home, "Library")
Paths = Dirs{
Data: filepath.Join(lib, "Application Support", BundleID),
Cache: filepath.Join(lib, "Caches", BundleID),
Log: filepath.Join(lib, "Logs", BundleID),
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !darwin
package paths
import (
"os"
"path/filepath"
)
func init() {
home, _ := os.UserHomeDir()
Paths = Dirs{
Data: filepath.Join(home, ".local", "share", BundleID),
Cache: filepath.Join(home, ".cache", BundleID),
Log: filepath.Join(home, ".local", "state", BundleID, "log"),
}
}
+68
View File
@@ -0,0 +1,68 @@
// Package protocol defines the LMVPN wire protocol structures and
// constants. These mirror the server's definitions in
// lmvpn_server/internal/vpn/{protocol,auth,tunnel}.go exactly.
//
// Wire format:
// - Text frames = UTF-8 JSON control messages (with "type" field)
// - Binary frames = raw IPv4/IPv6 packets (no encapsulation header)
package protocol
import "time"
// Message type strings exchanged over the WebSocket.
const (
TypeAuth = "auth" // C→S: password credentials
TypeAuthOK = "auth_ok" // S→C: password auth succeeded
TypeAuthErr = "auth_err" // S→C: auth failed (then close)
TypeInit = "init" // S→C: tunnel parameters
TypeReady = "ready" // C→S: TUN configured, data plane ready
TypeError = "error" // S→C: handshake failure (then close)
)
// Timeout and limit constants matching the server (tunnel.go:16-23).
const (
ReadTimeout = 60 * time.Second // post-ready read deadline
WriteTimeout = 10 * time.Second // per-write deadline
ReadyTimeout = 30 * time.Second // client must send ready within this
PingPeriod = 30 * time.Second // server ping interval
MaxMessageSize = 1 << 20 // 1 MiB max WebSocket message
MaxConnsPerUser = 3 // per-user concurrent connection cap
TokenExpiry = 24 * time.Hour // JWT validity
)
// InitMessage is sent by the server after auth + pre-checks pass.
// (server: protocol.go:3-9, tunnel.go:126-137)
type InitMessage struct {
Type string `json:"type"`
IP string `json:"ip"` // assigned client IP (dotted-quad)
Prefix int `json:"prefix"` // subnet prefix length (e.g. 24)
MTU int `json:"mtu"` // TUN device MTU (e.g. 1420)
ServerIP string `json:"server_ip"` // server's tunnel IP (peer/gateway)
}
// ControlMessage is the generic text control message.
// (server: protocol.go:11-13)
type ControlMessage struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
}
// AuthMessage is sent by the client for password authentication.
// (server: auth.go:17-21)
type AuthMessage struct {
Type string `json:"type"`
Username string `json:"username"`
Password string `json:"password"`
}
// AuthResponse is the server's reply to an AuthMessage.
// (server: auth.go:23-26)
type AuthResponse struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
}
// IsError reports whether a control message type indicates failure.
func IsError(msgType string) bool {
return msgType == TypeAuthErr || msgType == TypeError
}
+178
View File
@@ -0,0 +1,178 @@
// 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
//
// All routes are tracked so they can be cleanly removed on disconnect.
package route
import (
"fmt"
"net"
"strings"
)
// Mode selects which traffic goes through the VPN tunnel.
type Mode string
const (
ModeFull Mode = "full"
ModeSplit Mode = "split"
ModeCustom Mode = "custom"
)
// 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
ServerHost string // server hostname/IP (for full-tunnel bypass)
CustomCIDRs []string // for ModeCustom
}
// Manager applies and removes routes. It tracks all added routes so
// they can be cleaned up deterministically.
type Manager struct {
cfg Config
addedRoutes []string // route specs added, for deletion
serverBypass bool
originalGateway string
}
// NewManager creates a route manager for the given configuration.
func NewManager(cfg Config) *Manager {
return &Manager{cfg: cfg}
}
// Apply adds routes according to the configured mode.
func (m *Manager) Apply() error {
switch m.cfg.Mode {
case ModeFull:
return m.applyFull()
case ModeSplit:
return m.applySplit()
case ModeCustom:
return m.applyCustom()
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 {
var errs []string
for _, r := range m.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 {
errs = append(errs, err.Error())
}
m.serverBypass = false
}
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.
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)
if err != nil {
return fmt.Errorf("resolve server host %s: %w", m.cfg.ServerHost, err)
}
// Bypass: server's public IP via the original gateway (so the WS
// connection doesn't loop through the tunnel).
bypassSpec := serverIP + "/32"
if err := addRouteVia(bypassSpec, gw); err != nil {
return fmt.Errorf("add server bypass route: %w", err)
}
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.
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)
}
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)
return nil
}
func (m *Manager) applyCustom() error {
for _, cidr := range m.cfg.CustomCIDRs {
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)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
return nil
}
func (m *Manager) deleteServerBypass() error {
serverIP, err := resolveHost(m.cfg.ServerHost)
if err != nil {
return nil // best-effort
}
return deleteRouteVia(serverIP+"/32", m.originalGateway)
}
// 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)
}
mask := net.CIDRMask(prefix, 32)
network := ip.Mask(mask)
return fmt.Sprintf("%s/%d", network.String(), prefix)
}
// resolveHost resolves a hostname to an IP address. If already an IP,
// returns it directly.
func resolveHost(host string) (string, error) {
if net.ParseIP(host) != nil {
return host, nil
}
// Strip port if present.
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return "", fmt.Errorf("lookup %s: %w", host, err)
}
return ips[0].String(), nil
}
+59
View File
@@ -0,0 +1,59 @@
//go:build darwin
package route
import (
"fmt"
"os/exec"
"strings"
)
// addRoute adds a route via a network interface (macOS route command).
// route add -inet -net <cidr> -interface <iface>
func addRoute(cidr, iface string) error {
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
}
// deleteRoute removes a route via a network interface.
func deleteRoute(cidr, iface string) error {
return runRoute("delete", "-inet", "-net", cidr, "-interface", iface)
}
// addRouteVia adds a route via a gateway IP.
func addRouteVia(cidr, gateway string) error {
return runRoute("add", "-inet", "-net", cidr, gateway)
}
// deleteRouteVia removes a route via a gateway IP.
func deleteRouteVia(cidr, gateway string) error {
return runRoute("delete", "-inet", "-net", cidr, gateway)
}
// defaultGateway returns the current 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") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "gateway:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
gw := strings.TrimSpace(parts[1])
if gw != "" {
return gw, nil
}
}
}
}
return "", fmt.Errorf("no default gateway found")
}
func runRoute(args ...string) error {
cmd := exec.Command("route", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("route %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
//go:build !darwin
package route
import (
"fmt"
"os/exec"
"strings"
)
func addRoute(cidr, iface string) error {
return runCmd("ip", "route", "add", cidr, "dev", iface)
}
func deleteRoute(cidr, iface string) error {
return runCmd("ip", "route", "del", cidr, "dev", iface)
}
func addRouteVia(cidr, gateway string) error {
return runCmd("ip", "route", "add", cidr, "via", gateway)
}
func deleteRouteVia(cidr, gateway string) error {
return runCmd("ip", "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)))
for i, f := range fields {
if f == "via" && i+1 < len(fields) {
return fields[i+1], nil
}
}
return "", fmt.Errorf("no 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
}
+85
View File
@@ -0,0 +1,85 @@
// Package stats provides atomic counters for VPN session statistics.
package stats
import (
"sync/atomic"
"time"
)
// State represents the current VPN session state.
type State string
const (
StateDisconnected State = "disconnected"
StateConnecting State = "connecting"
StateConnected State = "connected"
StateReconnecting State = "reconnecting"
StateError State = "error"
)
// Stats holds live session statistics. Counters are atomic for
// lock-free reads from the UI/IPC layer.
type Stats struct {
RxBytes atomic.Int64
TxBytes atomic.Int64
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
state atomic.Value // State
assignedIP atomic.Value // string
}
// New creates a Stats instance initialised to the disconnected state.
func New() *Stats {
s := &Stats{}
s.state.Store(StateDisconnected)
s.assignedIP.Store("")
return s
}
// SetState updates the current state atomically.
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) {
s.ConnectedAt.Store(time.Now().Unix())
s.assignedIP.Store(ip)
s.state.Store(StateConnected)
}
// SetDisconnected clears the connection metadata.
func (s *Stats) SetDisconnected() {
s.ConnectedAt.Store(0)
s.assignedIP.Store("")
s.state.Store(StateDisconnected)
}
// AssignedIP returns the server-assigned tunnel IP.
func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
// Snapshot returns a point-in-time copy of all counters.
type Snapshot struct {
RxBytes int64
TxBytes int64
ConnectedAt time.Time
AssignedIP string
State State
Uptime time.Duration
}
// Snapshot returns a point-in-time copy of the statistics.
func (s *Stats) Snapshot() Snapshot {
snap := Snapshot{
RxBytes: s.RxBytes.Load(),
TxBytes: s.TxBytes.Load(),
AssignedIP: s.AssignedIP(),
State: s.State(),
}
ts := s.ConnectedAt.Load()
if ts > 0 {
snap.ConnectedAt = time.Unix(ts, 0)
snap.Uptime = time.Since(snap.ConnectedAt)
}
return snap
}
+274
View File
@@ -0,0 +1,274 @@
// Package transport implements the LMVPN WebSocket client transport.
//
// It handles the full connection lifecycle:
// 1. Dial the WebSocket (no Origin header, per server's allow-empty rule)
// 2. Authenticate — JWT via ?token= query param, or password via first
// text message {type:auth}
// 3. Receive the {type:init} message with tunnel parameters
// 4. Call the OnInit callback (TUN configuration)
// 5. Send {type:ready}
// 6. Provide ReadPacket/WritePacket for raw IP binary frames
//
// Reconnection with exponential backoff is handled by the vpn package's
// SessionManager, not here. This type represents a single connection.
package transport
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"lmvpn/internal/protocol"
"github.com/gorilla/websocket"
)
// HandshakeConfig configures a single connection attempt.
type HandshakeConfig struct {
ServerURL string // e.g. wss://vpn.example.com/ws
Token string // JWT; if non-empty, used via ?token= (method A)
Username string // for password auth (method B), or fallback
Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
}
// Conn is an established VPN tunnel connection.
type Conn struct {
ws *websocket.Conn
init protocol.InitMessage
writeMu sync.Mutex
closed bool
mu sync.Mutex
}
// Connect dials, authenticates, and completes the tunnel handshake.
// It blocks until the connection is ready for data transfer or an
// error occurs.
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
dialer := websocket.Dialer{
HandshakeTimeout: 15 * time.Second,
ReadBufferSize: 4096, // match server (handler.go:17)
WriteBufferSize: 4096, // match server (handler.go:18)
}
// Build URL: append ?token= for JWT auth.
url := cfg.ServerURL
if cfg.Token != "" {
url = appendQuery(url, "token", cfg.Token)
}
// Omit Origin header (server allows empty Origin for non-browser
// clients — handler.go:19-29).
header := http.Header{}
header.Set("Origin", "")
ws, resp, err := dialer.DialContext(ctx, url, header)
if err != nil {
if resp != nil {
resp.Body.Close()
}
return nil, fmt.Errorf("dial %s: %w", url, err)
}
defer resp.Body.Close()
ws.SetReadLimit(protocol.MaxMessageSize)
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 {
ws.Close()
return nil, err
}
}
// Step 3: receive init (or error/auth_err).
initMsg, err := conn.readInit()
if err != nil {
ws.Close()
return nil, err
}
conn.init = initMsg
// Step 4: configure TUN via callback.
if cfg.OnInit != nil {
if err := cfg.OnInit(initMsg); err != nil {
ws.Close()
return nil, fmt.Errorf("tun configure: %w", err)
}
}
// Step 5: send ready.
if err := conn.sendReady(); err != nil {
ws.Close()
return nil, fmt.Errorf("send ready: %w", err)
}
// 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).
ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
ws.SetPingHandler(func(appData string) error {
ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
return ws.WriteControl(websocket.PongMessage, []byte(appData),
time.Now().Add(protocol.WriteTimeout))
})
return conn, nil
}
// passwordAuth sends the {type:auth} message and waits for auth_ok.
func (c *Conn) passwordAuth(username, password string) error {
c.ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
msg := protocol.AuthMessage{
Type: protocol.TypeAuth,
Username: username,
Password: password,
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
if err := c.writeText(data); err != nil {
return fmt.Errorf("send auth: %w", err)
}
// Read auth response.
_, respData, err := c.ws.ReadMessage()
if err != nil {
return fmt.Errorf("read auth response: %w", err)
}
var resp protocol.AuthResponse
if err := json.Unmarshal(respData, &resp); err != nil {
return fmt.Errorf("parse auth response: %w", err)
}
if resp.Type == protocol.TypeAuthErr {
return &AuthError{Message: resp.Message}
}
if resp.Type != protocol.TypeAuthOK {
return fmt.Errorf("unexpected auth response type: %s", resp.Type)
}
return nil
}
// readInit reads the init message (or error/auth_err).
func (c *Conn) readInit() (protocol.InitMessage, error) {
c.ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
_, data, err := c.ws.ReadMessage()
if err != nil {
return protocol.InitMessage{}, fmt.Errorf("read init: %w", err)
}
// Try init first (most common path).
var initMsg protocol.InitMessage
if err := json.Unmarshal(data, &initMsg); err == nil && initMsg.Type == protocol.TypeInit {
return initMsg, nil
}
// Otherwise it's an error or auth_err control message.
var ctrl protocol.ControlMessage
if err := json.Unmarshal(data, &ctrl); err != nil {
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{}, fmt.Errorf("unexpected message type: %s", ctrl.Type)
}
// sendReady sends the {type:ready} control message.
func (c *Conn) sendReady() error {
data, _ := json.Marshal(protocol.ControlMessage{Type: protocol.TypeReady})
return c.writeText(data)
}
// writeText sends a text frame with the write deadline.
func (c *Conn) writeText(data []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
c.ws.SetWriteDeadline(time.Now().Add(protocol.WriteTimeout))
return c.ws.WriteMessage(websocket.TextMessage, data)
}
// ReadPacket reads the next binary IP packet from the tunnel.
// It blocks until a packet is available or the connection breaks.
func (c *Conn) ReadPacket() ([]byte, error) {
for {
msgType, data, err := c.ws.ReadMessage()
if err != nil {
return nil, err
}
if msgType == websocket.BinaryMessage {
return data, nil
}
// Text messages after handshake are ignored (server shouldn't
// send any, but be resilient).
}
}
// WritePacket sends a raw IP packet as a binary frame.
func (c *Conn) WritePacket(data []byte) error {
if len(data) == 0 {
return nil
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
c.ws.SetWriteDeadline(time.Now().Add(protocol.WriteTimeout))
return c.ws.WriteMessage(websocket.BinaryMessage, data)
}
// 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.
func (c *Conn) AssignedIP() string { return c.init.IP }
// Close terminates the connection.
func (c *Conn) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.mu.Unlock()
return c.ws.Close()
}
// AuthError indicates authentication failure (auth_err from server).
type AuthError struct{ Message string }
func (e *AuthError) Error() string { return "auth failed: " + e.Message }
// ServerError indicates a server-side rejection (error or auth_err).
type ServerError struct {
Type string
Message string
}
func (e *ServerError) Error() string {
return fmt.Sprintf("server %s: %s", e.Type, e.Message)
}
// appendQuery appends a key=value parameter to a URL.
func appendQuery(url, key, value string) string {
sep := "?"
if contains(url, "?") {
sep = "&"
}
return url + sep + key + "=" + value
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+29
View File
@@ -0,0 +1,29 @@
// Package tun provides a cross-platform TUN virtual network card
// abstraction. Each platform implements the Device interface.
//
// On macOS the TUN device is a utunN interface created via the
// songgao/water library (same as the server). Configuration uses
// ifconfig/route commands and requires root privileges.
package tun
import "net"
// Device represents a TUN virtual network interface.
type Device interface {
// Name returns the OS-assigned interface name (e.g. utun4).
Name() string
// Read reads one IP packet from the TUN device.
Read(p []byte) (int, error)
// Write writes one IP packet to the TUN device.
Write(p []byte) (int, error)
// Configure sets the interface address, prefix, and peer IP.
Configure(localIP net.IP, prefix int, peerIP net.IP) error
// SetMTU sets the interface MTU.
SetMTU(mtu int) error
// Close destroys the TUN device.
Close() error
}
// Create creates a new TUN device. If name is empty, the OS assigns
// a name (utunN on macOS, tunN on Linux).
func Create(name string) (Device, error) { return createTUN(name) }
+60
View File
@@ -0,0 +1,60 @@
//go:build darwin
package tun
import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"github.com/songgao/water"
)
type darwinDevice struct {
ifce *water.Interface
}
func createTUN(name string) (Device, error) {
cfg := water.Config{DeviceType: water.TUN}
cfg.Name = name
ifce, err := water.New(cfg)
if err != nil {
return nil, fmt.Errorf("create utun: %w", err)
}
return &darwinDevice{ifce: ifce}, nil
}
func (d *darwinDevice) Name() string { return d.ifce.Name() }
func (d *darwinDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) }
func (d *darwinDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) }
func (d *darwinDevice) Close() error { return d.ifce.Close() }
func (d *darwinDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
if localIP == nil {
return execCmd("ifconfig", d.Name(), "up")
}
inetType := "inet"
if localIP.To4() == nil {
inetType = "inet6"
}
localCidr := fmt.Sprintf("%s/%d", localIP.String(), prefix)
// ifconfig utunN inet <ip>/<prefix> <peer_ip> up
return execCmd("ifconfig", d.Name(), inetType, localCidr, peerIP.String(), "up")
}
func (d *darwinDevice) SetMTU(mtu int) error {
return execCmd("ifconfig", d.Name(), "mtu", fmt.Sprintf("%d", mtu))
}
// 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
}
+61
View File
@@ -0,0 +1,61 @@
//go:build !darwin
package tun
import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"github.com/songgao/water"
)
type linuxDevice struct {
ifce *water.Interface
}
func createTUN(name string) (Device, error) {
cfg := water.Config{DeviceType: water.TUN}
cfg.Name = name
ifce, err := water.New(cfg)
if err != nil {
return nil, fmt.Errorf("create tun: %w", err)
}
return &linuxDevice{ifce: ifce}, nil
}
func (d *linuxDevice) Name() string { return d.ifce.Name() }
func (d *linuxDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) }
func (d *linuxDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) }
func (d *linuxDevice) Close() error { return d.ifce.Close() }
func (d *linuxDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
if err := execCmd("ip", "link", "set", "dev", d.Name(), "up"); err != nil {
return err
}
if localIP == nil {
return nil
}
localCidr := fmt.Sprintf("%s/%d", localIP.String(), prefix)
if err := execCmd("ip", "addr", "add", "dev", d.Name(), localCidr, "peer", peerIP.String()); err != nil {
// Fall back without peer (some kernels).
return execCmd("ip", "addr", "add", "dev", d.Name(), localCidr)
}
return nil
}
func (d *linuxDevice) SetMTU(mtu int) error {
return execCmd("ip", "link", "set", "dev", d.Name(), "mtu", fmt.Sprintf("%d", mtu))
}
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
}
+170
View File
@@ -0,0 +1,170 @@
package ui
import (
"sync"
"lmvpn/internal/config"
"lmvpn/internal/db"
"lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/paths"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// App is the GUI application controller.
type App struct {
fyneApp fyne.App
db *db.Store
kc keychain.Store
window fyne.Window
// UI widgets
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
uptimeLabel *widget.Label
rxLabel *widget.Label
txLabel *widget.Label
connectBtn *widget.Button
disconnectBtn *widget.Button
// State
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
}
// Run initialises and starts the GUI application.
func Run() {
// Ensure platform directories exist.
if err := paths.EnsureDirs(); err != nil {
log.L().Error("ensure dirs", "error", err)
}
// Logging.
log.Init(log.RoleGUI, paths.LogFile())
// Database.
store, err := db.Open()
if err != nil {
log.L().Error("open db", "error", err)
}
// Load app config.
cfg, _ := config.Load()
a := &App{
fyneApp: app.NewWithID(paths.BundleID),
db: store,
kc: keychain.New(),
}
a.window = a.fyneApp.NewWindow("LMVPN")
a.window.SetContent(a.buildMainWindow())
a.window.Resize(fyne.NewSize(420, 480))
// Load profiles.
if store != nil {
a.loadProfiles()
}
// System tray.
a.setupTray()
// Auto-connect if configured.
if cfg.AutoConnect && a.currentProfile != nil {
go func() {
// Small delay to let window render.
a.onConnect()
}()
}
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.window.Hide()
} else {
a.fyneApp.Quit()
}
})
a.window.ShowAndRun()
}
// loadProfiles loads all profiles from the database and populates the
// selector.
func (a *App) loadProfiles() {
profiles, err := a.db.ListProfiles()
if err != nil {
log.L().Error("list profiles", "error", err)
return
}
a.profiles = profiles
names := a.profileNames()
a.profileSelect.Options = names
if len(names) > 0 {
a.profileSelect.SetSelectedIndex(0)
a.selectProfileByName(names[0])
}
}
// profileNames returns the names of all loaded profiles.
func (a *App) profileNames() []string {
names := make([]string, len(a.profiles))
for i, p := range a.profiles {
names[i] = p.Name
}
return names
}
// selectProfileByName sets the current profile by name.
func (a *App) selectProfileByName(name string) {
for i := range a.profiles {
if a.profiles[i].Name == name {
a.currentProfile = &a.profiles[i]
return
}
}
}
// 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.ShowInformation("No Profile", "Select a profile to edit.", a.window)
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.ShowConfirm("Delete Profile",
"Delete profile \""+name+"\" and its stored credentials?",
func(ok bool) {
if !ok {
return
}
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
showError("Error", err.Error(), a.window)
return
}
_ = a.kc.DeleteAll(name)
a.loadProfiles()
}, a.window)
}
+50
View File
@@ -0,0 +1,50 @@
// Package ui contains the Fyne desktop GUI. It runs as the user,
// manages profiles and credentials (SQLite + Keychain), and controls
// the privileged daemon over IPC.
package ui
import (
"fmt"
"os"
"os/exec"
"time"
"lmvpn/internal/ipc"
"lmvpn/internal/log"
"lmvpn/internal/paths"
)
// ensureDaemon checks if the daemon is running and launches it (as
// root via osascript) if not. It blocks until the daemon is reachable
// or times out.
func ensureDaemon() (*ipc.Client, error) {
// Fast path: daemon already running.
if c, err := ipc.Dial(); err == nil {
return c, nil
}
exe, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve executable: %w", err)
}
// Launch daemon as root via osascript administrator prompt.
script := fmt.Sprintf(
`do shell script "nohup %s daemon > /dev/null 2>&1 &" with administrator privileges`,
exe)
cmd := exec.Command("osascript", "-e", script)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("launch daemon: %w (%s)", err, string(out))
}
log.L().Info("daemon launched via osascript")
// Wait for the daemon to become reachable.
for i := 0; i < 20; i++ {
time.Sleep(250 * time.Millisecond)
if c, err := ipc.Dial(); err == nil {
log.L().Info("daemon connected", "socket", paths.IPCSocketPath())
return c, nil
}
}
return nil, fmt.Errorf("daemon did not become reachable")
}
+134
View File
@@ -0,0 +1,134 @@
package ui
import (
"strconv"
"lmvpn/internal/model"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// showProfileDialog displays an add/edit dialog for a server profile.
// If editing is nil, a new profile is created.
func (a *App) showProfileDialog(editing *model.ServerProfile) {
isNew := editing == nil
nameEntry := widget.NewEntry()
serverEntry := widget.NewEntry()
userEntry := widget.NewEntry()
passEntry := widget.NewPasswordEntry()
authSelect := widget.NewSelect([]string{"both", "jwt", "password"}, nil)
routeSelect := widget.NewSelect([]string{"full", "split", "custom"}, nil)
cidrEntry := widget.NewMultiLineEntry()
cidrEntry.SetPlaceHolder("10.0.0.0/8, 172.16.0.0/12")
mtuEntry := widget.NewEntry()
mtuEntry.SetPlaceHolder("0 = use server MTU")
if !isNew {
nameEntry.SetText(editing.Name)
serverEntry.SetText(editing.ServerURL)
userEntry.SetText(editing.Username)
authSelect.SetSelected(string(editing.AuthMode))
routeSelect.SetSelected(string(editing.RoutingMode))
cidrEntry.SetText(editing.CustomCIDRs)
mtuEntry.SetText(fmtInt(editing.MTUOverride))
passEntry.SetPlaceHolder("(unchanged)")
} else {
authSelect.SetSelected(string(model.AuthModeBoth))
routeSelect.SetSelected(string(model.RoutingFull))
mtuEntry.SetText("0")
}
form := container.NewVBox(
widget.NewLabel("Name"), nameEntry,
widget.NewLabel("Server URL"), serverEntry,
widget.NewLabel("Username"), userEntry,
widget.NewLabel("Password"), passEntry,
widget.NewLabel("Auth Mode"), authSelect,
widget.NewLabel("Routing Mode"), routeSelect,
widget.NewLabel("Custom CIDRs (comma-separated)"), cidrEntry,
widget.NewLabel("MTU Override"), mtuEntry,
)
d := dialog.NewCustomConfirm("Profile", "Save", "Cancel", form, func(save bool) {
if !save {
return
}
a.saveProfile(editing, nameEntry.Text, serverEntry.Text,
userEntry.Text, passEntry.Text,
authSelect.Selected, routeSelect.Selected,
cidrEntry.Text, mtuEntry.Text, isNew)
}, a.window)
d.Resize(fyne.NewSize(400, 500))
d.Show()
}
// saveProfile creates or updates a profile and stores credentials.
func (a *App) saveProfile(editing *model.ServerProfile,
name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) {
if name == "" || server == "" || user == "" {
showError("Validation", "Name, Server URL, and Username are required.", a.window)
return
}
mtu := parseIntDefault(mtuStr, 0)
if isNew {
p := &model.ServerProfile{
Name: name,
ServerURL: server,
Username: user,
AuthMode: model.AuthMode(authMode),
RoutingMode: model.RoutingMode(routeMode),
CustomCIDRs: cidrs,
MTUOverride: mtu,
}
id, err := a.db.CreateProfile(p)
if err != nil {
showError("Save Error", err.Error(), a.window)
return
}
_ = id
if password != "" {
if err := a.kc.SetPassword(name, password); err != nil {
showError("Keychain Error", err.Error(), a.window)
}
}
} else {
oldName := editing.Name
editing.Name = name
editing.ServerURL = server
editing.Username = user
editing.AuthMode = model.AuthMode(authMode)
editing.RoutingMode = model.RoutingMode(routeMode)
editing.CustomCIDRs = cidrs
editing.MTUOverride = mtu
if err := a.db.UpdateProfile(editing); err != nil {
showError("Save Error", err.Error(), a.window)
return
}
if password != "" {
_ = a.kc.DeleteAll(oldName)
if err := a.kc.SetPassword(name, password); err != nil {
showError("Keychain Error", err.Error(), a.window)
}
}
}
a.loadProfiles()
}
func fmtInt(n int) string {
return strconv.Itoa(n)
}
func parseIntDefault(s string, def int) int {
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
+32
View File
@@ -0,0 +1,32 @@
package ui
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/driver/desktop"
)
// setupTray configures the system tray menu (desktop only).
func (a *App) setupTray() {
deskApp, ok := a.fyneApp.(desktop.App)
if !ok {
return // not a desktop app, skip tray
}
menu := fyne.NewMenu("LMVPN",
fyne.NewMenuItem("Show Window", func() {
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Connect", func() {
a.onConnect()
}),
fyne.NewMenuItem("Disconnect", func() {
a.onDisconnect()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Quit", func() {
a.fyneApp.Quit()
}),
)
deskApp.SetSystemTrayMenu(menu)
}
+276
View File
@@ -0,0 +1,276 @@
package ui
import (
"fmt"
"time"
"lmvpn/internal/ipc"
"lmvpn/internal/stats"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// showError displays a titled error dialog.
func showError(title, message string, parent fyne.Window) {
dialog.NewCustom(title, "OK", widget.NewLabel(message), parent).Show()
}
// 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)
})
// Status display.
a.stateLabel = widget.NewLabel("Disconnected")
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
a.ipLabel = widget.NewLabel("IP: —")
a.uptimeLabel = widget.NewLabel("Uptime: —")
a.rxLabel = widget.NewLabel("↓ 0 B")
a.txLabel = widget.NewLabel("↑ 0 B")
statusCard := widget.NewCard("Status", "", container.NewVBox(
a.stateLabel,
a.ipLabel,
a.uptimeLabel,
container.NewHBox(a.rxLabel, a.txLabel),
))
// Buttons.
a.connectBtn = widget.NewButton("Connect", a.onConnect)
a.connectBtn.Importance = widget.HighImportance
a.disconnectBtn = widget.NewButton("Disconnect", a.onDisconnect)
a.disconnectBtn.Disable()
addBtn := widget.NewButton("Add Profile", a.onAddProfile)
editBtn := widget.NewButton("Edit", a.onEditProfile)
deleteBtn := widget.NewButton("Delete", a.onDeleteProfile)
buttons := container.NewGridWithColumns(2,
a.connectBtn, a.disconnectBtn,
)
profileButtons := container.NewGridWithColumns(3,
addBtn, editBtn, deleteBtn,
)
return container.NewVBox(
widget.NewLabel("Profile"),
a.profileSelect,
buttons,
profileButtons,
statusCard,
)
}
// onConnect handles the Connect button click.
func (a *App) onConnect() {
if a.currentProfile == nil {
showError("No Profile", "Please select or create a profile first.", a.window)
return
}
a.connectBtn.Disable()
a.stateLabel.SetText("Connecting...")
go func() {
// Ensure daemon is running.
client, err := ensureDaemon()
if err != nil {
fyne.Do(func() {
showError("Daemon Error", err.Error(), a.window)
a.stateLabel.SetText("Disconnected")
a.connectBtn.Enable()
})
return
}
a.mu.Lock()
a.ipcClient = client
a.mu.Unlock()
// Get password from keychain.
password, err := a.kc.GetPassword(a.currentProfile.Name)
if err != nil {
fyne.Do(func() {
showError("Credential Error",
"No password stored for this profile. Edit the profile to set it.",
a.window)
a.stateLabel.SetText("Disconnected")
a.connectBtn.Enable()
})
return
}
// Build and send the start command.
cfg := ipc.ClientConfig{
ServerURL: a.currentProfile.ServerURL,
Username: a.currentProfile.Username,
Password: password,
AuthMode: string(a.currentProfile.AuthMode),
RoutingMode: string(a.currentProfile.RoutingMode),
CustomCIDRs: splitCIDRs(a.currentProfile.CustomCIDRs),
MTUOverride: a.currentProfile.MTUOverride,
}
if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() {
showError("IPC Error", err.Error(), a.window)
a.stateLabel.SetText("Disconnected")
a.connectBtn.Enable()
})
return
}
// Start the event listener.
go a.eventLoop()
}()
}
// onDisconnect handles the Disconnect button click.
func (a *App) onDisconnect() {
a.mu.Lock()
client := a.ipcClient
a.mu.Unlock()
if client == nil {
return
}
_ = ipc.SendStop(client)
a.disconnectBtn.Disable()
a.connectBtn.Enable()
a.stateLabel.SetText("Disconnected")
}
// eventLoop reads IPC events from the daemon and updates the UI.
func (a *App) eventLoop() {
for {
a.mu.Lock()
client := a.ipcClient
a.mu.Unlock()
if client == nil {
return
}
ev, err := client.Recv()
if err != nil {
fyne.Do(func() {
a.stateLabel.SetText("Disconnected")
a.ipLabel.SetText("IP: —")
a.uptimeLabel.SetText("Uptime: —")
a.rxLabel.SetText("↓ 0 B")
a.txLabel.SetText("↑ 0 B")
a.connectBtn.Enable()
a.disconnectBtn.Disable()
})
return
}
switch ev.Event {
case ipc.EvState:
fyne.Do(func() {
a.applyState(ev.State)
})
case ipc.EvStats:
if ev.Stats != nil {
s := *ev.Stats
fyne.Do(func() {
a.applyStats(s)
})
}
case ipc.EvError:
fyne.Do(func() {
if ev.Message != "" {
showError("VPN Error", ev.Message, a.window)
}
})
}
}
}
// applyState updates UI elements for a state change.
func (a *App) applyState(state string) {
switch stats.State(state) {
case stats.StateConnected:
a.stateLabel.SetText("Connected")
a.connectBtn.Disable()
a.disconnectBtn.Enable()
case stats.StateConnecting:
a.stateLabel.SetText("Connecting...")
a.connectBtn.Disable()
a.disconnectBtn.Enable()
case stats.StateReconnecting:
a.stateLabel.SetText("Reconnecting...")
a.disconnectBtn.Enable()
case stats.StateDisconnected:
a.stateLabel.SetText("Disconnected")
a.ipLabel.SetText("IP: —")
a.connectBtn.Enable()
a.disconnectBtn.Disable()
case stats.StateError:
a.stateLabel.SetText("Error")
a.connectBtn.Enable()
a.disconnectBtn.Disable()
}
}
// applyStats updates the stats display.
func (a *App) applyStats(s stats.Snapshot) {
if s.AssignedIP != "" {
a.ipLabel.SetText("IP: " + s.AssignedIP)
}
if s.State == stats.StateConnected {
a.stateLabel.SetText("Connected")
}
a.rxLabel.SetText("↓ " + formatBytes(s.RxBytes))
a.txLabel.SetText("↑ " + formatBytes(s.TxBytes))
if s.Uptime > 0 {
a.uptimeLabel.SetText("Uptime: " + formatDuration(s.Uptime))
}
}
// formatBytes formats a byte count human-readably.
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatDuration formats an uptime duration.
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm %ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
// splitCIDRs splits a comma-separated CIDR string into a slice.
func splitCIDRs(s string) []string {
if s == "" {
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
}
}
return out
}
+436
View File
@@ -0,0 +1,436 @@
// Package vpn orchestrates the full VPN session lifecycle: transport
// connection, TUN device setup, route management, the bidirectional
// packet pump, and automatic reconnection with exponential backoff.
//
// A SessionManager runs in its own goroutine once Connect is called.
// State changes and periodic stats are reported via callbacks, making
// it suitable for both the daemon (IPC) and headless use.
package vpn
import (
"context"
"errors"
"fmt"
"net"
"sync"
"time"
"lmvpn/internal/auth"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/protocol"
"lmvpn/internal/route"
"lmvpn/internal/stats"
"lmvpn/internal/transport"
"lmvpn/internal/tun"
)
// SessionConfig describes how to connect to a VPN server.
type SessionConfig struct {
ServerURL string
Username string
Password string
AuthMode model.AuthMode
Token string // pre-obtained JWT (empty = fetch via HTTP login)
RoutingMode route.Mode
CustomCIDRs []string
MTUOverride int // 0 = use server MTU
}
// SessionManager manages a single VPN session with auto-reconnect.
type SessionManager struct {
stats *stats.Stats
onState func(stats.State)
onStats func(stats.Snapshot)
mu sync.Mutex
running bool
cancel context.CancelFunc
dev tun.Device
routeMgr *route.Manager
conn *transport.Conn
}
// 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 {
return &SessionManager{
stats: stats.New(),
onState: onState,
onStats: onStats,
}
}
// Stats returns the live stats handle.
func (sm *SessionManager) Stats() *stats.Stats { return sm.stats }
// State returns the current session state.
func (sm *SessionManager) State() stats.State { return sm.stats.State() }
// Connect starts the VPN session. It returns immediately; the session
// runs in a background goroutine until Disconnect is called or the
// context is cancelled. If already running, it returns an error.
func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error {
sm.mu.Lock()
if sm.running {
sm.mu.Unlock()
return errors.New("session already running")
}
ctx, cancel := context.WithCancel(ctx)
sm.cancel = cancel
sm.running = true
sm.mu.Unlock()
go sm.run(ctx, cfg)
return nil
}
// Disconnect stops the session and cleans up resources. It blocks
// until the session has fully shut down.
func (sm *SessionManager) Disconnect() {
sm.mu.Lock()
if !sm.running {
sm.mu.Unlock()
return
}
sm.running = false
cancel := sm.cancel
sm.mu.Unlock()
if cancel != nil {
cancel()
}
// Close the transport to unblock the packet pump.
sm.mu.Lock()
conn := sm.conn
sm.mu.Unlock()
if conn != nil {
conn.Close()
}
}
// run is the main session loop with exponential-backoff reconnection.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
defer sm.setState(stats.StateDisconnected)
backoff := time.Second
maxBackoff := 60 * time.Second
for {
if ctx.Err() != nil {
return
}
err := sm.connectOnce(ctx, cfg)
if ctx.Err() != nil {
sm.cleanup()
return
}
if err != nil {
log.L().Error("VPN connection failed", "error", err)
sm.setState(stats.StateReconnecting)
} else {
sm.setState(stats.StateReconnecting)
}
// Wait before reconnecting, unless cancelled.
select {
case <-ctx.Done():
sm.cleanup()
return
case <-time.After(backoff):
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
// 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) error {
sm.setState(stats.StateConnecting)
// Determine auth strategy and obtain JWT if needed.
token := cfg.Token
if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) {
httpBase, err := auth.WSURLToHTTP(cfg.ServerURL)
if err != nil {
return fmt.Errorf("parse server URL: %w", err)
}
result, err := auth.Login(httpBase, cfg.Username, cfg.Password)
if err != nil {
if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth.
token = ""
} else {
return fmt.Errorf("login: %w", err)
}
} else {
token = result.Token
}
}
// Prepare the TUN + route setup callback (called during handshake,
// between receiving init and sending ready).
handshake := transport.HandshakeConfig{
ServerURL: cfg.ServerURL,
Token: token,
Username: cfg.Username,
Password: cfg.Password,
OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg)
},
}
// Attempt JWT connection first; fall back to password on auth error.
conn, err := transport.Connect(ctx, handshake)
if err != nil {
var authErr *transport.AuthError
if errors.As(err, &authErr) && cfg.AuthMode == model.AuthModeBoth && token != "" {
// Retry with password auth (no token).
handshake.Token = ""
conn, err = transport.Connect(ctx, handshake)
}
if err != nil {
return err
}
}
sm.mu.Lock()
sm.conn = conn
sm.mu.Unlock()
// If password auth was used (no token), the handshake already
// exchanged auth messages. For JWT, auth was implicit.
if token == "" {
// Password auth path already validated.
}
sm.stats.SetConnected(conn.Init().IP)
sm.setState(stats.StateConnected)
log.L().Info("VPN connected",
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
"mtu", conn.Init().MTU)
// Start stats reporter.
statsDone := make(chan struct{})
go sm.reportStats(statsDone, ctx)
// Run the packet pump (blocks until connection breaks).
sm.pumpPackets(ctx, conn)
close(statsDone)
sm.cleanup()
return nil
}
// 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 {
dev, err := tun.Create("")
if err != nil {
return fmt.Errorf("create tun: %w", err)
}
sm.mu.Lock()
sm.dev = dev
sm.mu.Unlock()
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)
}
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)
}
// Apply routing.
routeCfg := route.Config{
Mode: cfg.RoutingMode,
InterfaceName: dev.Name(),
VPNIP: init.IP,
VPNPrefix: init.Prefix,
ServerHost: serverHostFromURL(cfg.ServerURL),
CustomCIDRs: cfg.CustomCIDRs,
}
sm.routeMgr = route.NewManager(routeCfg)
if err := sm.routeMgr.Apply(); err != nil {
log.L().Error("route apply failed (continuing)", "error", err)
}
log.L().Info("TUN configured",
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, "mtu", mtu)
return nil
}
// pumpPackets runs the bidirectional packet loop until the connection
// breaks.
func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) {
var wg sync.WaitGroup
wg.Add(2)
// TUN → WebSocket
go func() {
defer wg.Done()
buf := make([]byte, 65536)
for {
select {
case <-ctx.Done():
return
default:
}
n, err := sm.readTUN(buf)
if err != nil {
if ctx.Err() == nil {
log.L().Error("tun read error", "error", err)
}
conn.Close()
return
}
if n == 0 {
continue
}
if err := conn.WritePacket(buf[:n]); err != nil {
if ctx.Err() == nil {
log.L().Error("ws write error", "error", err)
}
return
}
sm.stats.TxBytes.Add(int64(n))
}
}()
// WebSocket → TUN
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
}
data, err := conn.ReadPacket()
if err != nil {
if ctx.Err() == nil {
log.L().Error("ws read error", "error", err)
}
return
}
if _, err := sm.writeTUN(data); err != nil {
if ctx.Err() == nil {
log.L().Error("tun write error", "error", err)
}
conn.Close()
return
}
sm.stats.RxBytes.Add(int64(len(data)))
}
}()
wg.Wait()
}
// cleanup tears down the TUN device and routes.
func (sm *SessionManager) cleanup() {
sm.mu.Lock()
dev := sm.dev
routeMgr := sm.routeMgr
conn := sm.conn
sm.dev = nil
sm.routeMgr = nil
sm.conn = nil
sm.mu.Unlock()
if routeMgr != nil {
if err := routeMgr.Cleanup(); err != nil {
log.L().Error("route cleanup error", "error", err)
}
}
if dev != nil {
dev.Close()
}
if conn != nil {
conn.Close()
}
sm.stats.SetDisconnected()
}
func (sm *SessionManager) readTUN(p []byte) (int, error) {
sm.mu.Lock()
dev := sm.dev
sm.mu.Unlock()
if dev == nil {
return 0, errors.New("tun device not available")
}
return dev.Read(p)
}
func (sm *SessionManager) writeTUN(p []byte) (int, error) {
sm.mu.Lock()
dev := sm.dev
sm.mu.Unlock()
if dev == nil {
return 0, errors.New("tun device not available")
}
return dev.Write(p)
}
func (sm *SessionManager) setState(s stats.State) {
sm.stats.SetState(s)
if sm.onState != nil {
sm.onState(s)
}
}
// reportStats periodically calls the onStats callback while connected.
func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-ticker.C:
if sm.onStats != nil {
sm.onStats(sm.stats.Snapshot())
}
}
}
}
// serverHostFromURL extracts the host portion from a WebSocket URL.
func serverHostFromURL(wsURL string) string {
u := wsURL
for _, p := range []string{"wss://", "ws://"} {
if len(u) > len(p) && u[:len(p)] == p {
u = u[len(p):]
break
}
}
// Strip path.
for i := 0; i < len(u); i++ {
if u[i] == '/' {
u = u[:i]
break
}
}
return u
}
+30
View File
@@ -0,0 +1,30 @@
// Command lmvpn is the LMVPN client application. It runs in two modes:
//
// lmvpn — GUI mode (default): Fyne desktop application
// lmvpn daemon — privileged daemon: owns the TUN device and
// WebSocket transport, launched as root by the GUI
//
// The split architecture keeps the GUI running as the user (with
// Keychain access) while the daemon runs as root (with TUN/route
// privileges). They communicate over a unix domain socket.
package main
import (
"fmt"
"os"
"lmvpn/internal/daemon"
"lmvpn/internal/ui"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "daemon" {
if err := daemon.Run(); err != nil {
fmt.Fprintf(os.Stderr, "daemon: %v\n", err)
os.Exit(1)
}
return
}
ui.Run()
}
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>lmvpn</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>com.lmvpn.client</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>LMVPN</string>
<key>CFBundleDisplayName</key>
<string>LMVPN</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSUIElement</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+71
View File
@@ -0,0 +1,71 @@
// genicon generates a simple 1024x1024 PNG application icon for LMVPN.
// Run: go run resources/genicon/main.go
package main
import (
"image"
"image/color"
"image/draw"
"image/png"
"math"
"os"
)
const size = 1024
func main() {
img := image.NewRGBA(image.Rect(0, 0, size, size))
// Background: dark blue gradient.
bg := color.RGBA{R: 30, G: 60, B: 120, A: 255}
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// Draw a lighter blue circle in the center.
cx, cy := size/2, size/2
radius := size / 3
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := float64(x - cx)
dy := float64(y - cy)
dist := math.Sqrt(dx*dx + dy*dy)
if dist < float64(radius) {
t := dist / float64(radius)
r := uint8(50 + float64(100)*(1-t))
g := uint8(100 + float64(100)*(1-t))
b := uint8(200 + float64(55)*(1-t))
img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
}
}
}
// Draw a white shield outline (simplified).
shieldColor := color.RGBA{R: 255, G: 255, B: 255, A: 230}
sx, sy := size/2, size/4
sw, sh := size/3, size/2
for y := sy; y < sy+sh; y++ {
for x := sx - sw/2; x < sx+sw/2; x++ {
// Shield shape: rounded top, pointed bottom.
progress := float64(y-sy) / float64(sh)
halfW := float64(sw)/2 * (1.0 - 0.3*progress*progress)
if math.Abs(float64(x-sx)) < halfW && progress < 0.7 {
img.SetRGBA(x, y, shieldColor)
} else if progress >= 0.7 {
tp := (progress - 0.7) / 0.3
halfW2 := float64(sw)/2 * (1.0 - 0.3*0.49) * (1.0 - tp)
if math.Abs(float64(x-sx)) < halfW2 {
img.SetRGBA(x, y, shieldColor)
}
}
}
}
f, err := os.Create("resources/icon.png")
if err != nil {
panic(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
panic(err)
}
println("Generated resources/icon.png")
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB