commit bb9f68522123b41233eec37dac11b5b07d9d3f2b Author: kevin Date: Mon Jul 6 16:21:06 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8052fb0 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01edfcf --- /dev/null +++ b/Makefile @@ -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) diff --git a/build/lmvpn b/build/lmvpn new file mode 100755 index 0000000..738f0d7 Binary files /dev/null and b/build/lmvpn differ diff --git a/docs/client-development.md b/docs/client-development.md new file mode 100644 index 0000000..a308834 --- /dev/null +++ b/docs/client-development.md @@ -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= +wss://host/ws?token= +``` + +服务端处理逻辑(`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 up +ip addr add dev / peer +ip link set dev mtu +``` + +路由:若需将所有流量经 VPN,添加默认路由: + +``` +ip route add 0.0.0.0/0 dev +``` + +### 11.3 macOS + +参考服务端实现(`internal/vpn/tun_darwin.go`)。macOS 的 TUN 设备名为 `utunN`,使用 `ifconfig`: + +``` +ifconfig inet / up +ifconfig mtu +``` + +路由: + +``` +route add -inet -net 0.0.0.0/0 -interface +``` + +> 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` | 服务端配置结构 | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f35a67 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..882eb81 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..1839765 --- /dev/null +++ b/internal/auth/auth.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b58d4da --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..2faf6eb --- /dev/null +++ b/internal/daemon/daemon.go @@ -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 + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..14a7463 --- /dev/null +++ b/internal/db/db.go @@ -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); +` diff --git a/internal/db/log.go b/internal/db/log.go new file mode 100644 index 0000000..b87be99 --- /dev/null +++ b/internal/db/log.go @@ -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() +} diff --git a/internal/db/profile.go b/internal/db/profile.go new file mode 100644 index 0000000..64a014e --- /dev/null +++ b/internal/db/profile.go @@ -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 +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go new file mode 100644 index 0000000..9dce92f --- /dev/null +++ b/internal/ipc/ipc.go @@ -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 + } +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go new file mode 100644 index 0000000..4ea78cb --- /dev/null +++ b/internal/keychain/keychain.go @@ -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 +} diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go new file mode 100644 index 0000000..a2bc285 --- /dev/null +++ b/internal/keychain/keychain_darwin.go @@ -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) +} diff --git a/internal/keychain/keychain_other.go b/internal/keychain/keychain_other.go new file mode 100644 index 0000000..8e176c8 --- /dev/null +++ b/internal/keychain/keychain_other.go @@ -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 +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..6f730a9 --- /dev/null +++ b/internal/log/log.go @@ -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 +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..fd32b5f --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..936e720 --- /dev/null +++ b/internal/paths/paths.go @@ -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" } diff --git a/internal/paths/paths_darwin.go b/internal/paths/paths_darwin.go new file mode 100644 index 0000000..637b0bb --- /dev/null +++ b/internal/paths/paths_darwin.go @@ -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), + } +} diff --git a/internal/paths/paths_other.go b/internal/paths/paths_other.go new file mode 100644 index 0000000..601c139 --- /dev/null +++ b/internal/paths/paths_other.go @@ -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"), + } +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..1adeb36 --- /dev/null +++ b/internal/protocol/protocol.go @@ -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 +} diff --git a/internal/route/route.go b/internal/route/route.go new file mode 100644 index 0000000..b44dcf3 --- /dev/null +++ b/internal/route/route.go @@ -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 +} diff --git a/internal/route/route_darwin.go b/internal/route/route_darwin.go new file mode 100644 index 0000000..eb29e93 --- /dev/null +++ b/internal/route/route_darwin.go @@ -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 -interface +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 +} diff --git a/internal/route/route_linux.go b/internal/route/route_linux.go new file mode 100644 index 0000000..713a062 --- /dev/null +++ b/internal/route/route_linux.go @@ -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 +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..fe43bf4 --- /dev/null +++ b/internal/stats/stats.go @@ -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 +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..23f1f69 --- /dev/null +++ b/internal/transport/transport.go @@ -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 +} diff --git a/internal/tun/tun.go b/internal/tun/tun.go new file mode 100644 index 0000000..61f0c64 --- /dev/null +++ b/internal/tun/tun.go @@ -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) } diff --git a/internal/tun/tun_darwin.go b/internal/tun/tun_darwin.go new file mode 100644 index 0000000..942ef50 --- /dev/null +++ b/internal/tun/tun_darwin.go @@ -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 / 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 +} diff --git a/internal/tun/tun_linux.go b/internal/tun/tun_linux.go new file mode 100644 index 0000000..a11f4fc --- /dev/null +++ b/internal/tun/tun_linux.go @@ -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 +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..6a23e35 --- /dev/null +++ b/internal/ui/app.go @@ -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) +} diff --git a/internal/ui/daemon.go b/internal/ui/daemon.go new file mode 100644 index 0000000..6d84242 --- /dev/null +++ b/internal/ui/daemon.go @@ -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") +} diff --git a/internal/ui/profile.go b/internal/ui/profile.go new file mode 100644 index 0000000..c4d3c83 --- /dev/null +++ b/internal/ui/profile.go @@ -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 +} diff --git a/internal/ui/tray.go b/internal/ui/tray.go new file mode 100644 index 0000000..5d8be04 --- /dev/null +++ b/internal/ui/tray.go @@ -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) +} diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..b1b9698 --- /dev/null +++ b/internal/ui/view.go @@ -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 +} diff --git a/internal/vpn/session.go b/internal/vpn/session.go new file mode 100644 index 0000000..0937991 --- /dev/null +++ b/internal/vpn/session.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4356675 --- /dev/null +++ b/main.go @@ -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() +} diff --git a/resources/Info.plist b/resources/Info.plist new file mode 100644 index 0000000..401381e --- /dev/null +++ b/resources/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + lmvpn + CFBundleIconFile + icon.icns + CFBundleIdentifier + com.lmvpn.client + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + LMVPN + CFBundleDisplayName + LMVPN + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + LSUIElement + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/resources/genicon/main.go b/resources/genicon/main.go new file mode 100644 index 0000000..3a11b5c --- /dev/null +++ b/resources/genicon/main.go @@ -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") +} diff --git a/resources/icon.icns b/resources/icon.icns new file mode 100644 index 0000000..6d5f2d2 Binary files /dev/null and b/resources/icon.icns differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..7337414 Binary files /dev/null and b/resources/icon.png differ