init
This commit is contained in:
+31
@@ -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/
|
||||
@@ -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
Binary file not shown.
@@ -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` |
|
||||
| 协议 | WebSocket(RFC 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 方式 A:JWT(query 参数)
|
||||
|
||||
#### 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 | 会话 ID(UUID) |
|
||||
| `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 | root(sudo) |
|
||||
| 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 范围 | 500–65535 | 配置校验范围 | `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` | 服务端配置结构 |
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
Reference in New Issue
Block a user