Files
lmvpn_server/docs/client-development.md
kevin c63440435e feat: 每用户最大连接数改为可配置,默认 30
将 maxConnsPerUser 从硬编码常量(3)改为数据库动态配置项,管理员可在
/admin/vpn 隧道设置中调整,保存后对新连接立即生效。

- model/vpn.go: VpnSetting 新增 MaxConnsPerUser 字段,gorm default:30
- db/db.go: 种子数据设默认 30;旧库回填 0 值为 30
- vpn/tunnel.go: 删除 maxConnsPerUser 常量,改读 VPN.Settings(),兜底 30
- handler/vpn.go: API 响应/请求结构体新增字段,校验范围 1-1000
- VpnView.vue: 隧道设置表单新增"每用户最大连接数"输入框
- zh.ts/en.ts: 新增 maxConnsPerUser 文案,更新首页多设备描述
- docs/client-development.md: 更新常量表为可配置项
2026-07-10 13:44:46 +08:00

817 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LMVPN 客户端开发文档
> 本文档面向 LMVPN 客户端开发者,描述客户端与服务端之间的通信协议规范。
>
> **数据来源**:本文档所有字段、阈值、行为描述均严格对应 `lmvpn_server` 的 Go 源码实现,标注格式为 `文件:行号`,便于核对。本文档不依赖任何测试脚本或外部示例。
>
> **定位**:纯协议规范,平台无关,不含完整客户端实现代码。客户端开发者可据此在任意语言/平台实现兼容的客户端。
---
## 目录
1. [概述与架构](#1-概述与架构)
2. [术语与消息分类](#2-术语与消息分类)
3. [传输层规范](#3-传输层规范)
4. [认证协议](#4-认证协议)
5. [隧道握手协议](#5-隧道握手协议)
6. [数据平面](#6-数据平面)
7. [心跳与保活](#7-心跳与保活)
8. [限制与配额](#8-限制与配额)
9. [错误码与控制消息](#9-错误码与控制消息)
10. [消息格式速查表](#10-消息格式速查表)
11. [TUN 接口配置说明](#11-tun-接口配置说明)
12. [服务端配套依赖](#12-服务端配套依赖)
13. [典型问题排查](#13-典型问题排查)
14. [附录 A:关键常量表](#附录-a关键常量表)
15. [附录 B:源码文件索引](#附录-b源码文件索引)
---
## 1. 概述与架构
### 1.1 什么是 LMVPN
LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的 VPN 系统。服务端通过 WebSocket 与客户端建立控制通道,TUN 网卡与 WebSocket 之间双向搬运原始 IP 数据包,实现三层(网络层)VPN 隧道。
### 1.2 架构拓扑
```
┌─────────────┐ WebSocket (ws/wss) ┌─────────────┐
│ 客户端 │ ◄─────────────────────────────────► │ 服务端 │
│ │ 控制消息: 文本帧 (JSON) │ │
│ TUN 网卡 │ 数据消息: 二进制帧 (IP 包) │ TUN 网卡 │
│ (utun/tun) │ │ (tun0/...) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 应用层流量 │ 物理网卡
▼ ▼
应用程序 公网 / 局域网
```
### 1.3 工作原理
1. 客户端通过 WebSocket 连接服务端 `/ws` 端点
2. 完成身份认证(JWT 或用户名/密码)
3. 服务端为客户端分配 VPN 内网 IP,发送 `init` 消息
4. 客户端据此配置本地 TUN 网卡,回复 `ready`
5. 此后双方通过二进制 WebSocket 帧互传 IP 数据包:
- 客户端 TUN 读到的包 → WebSocket 二进制帧 → 服务端 TUN 写入(出网)或转发给其他客户端
- 服务端 TUN 读到的包 → WebSocket 二进制帧 → 客户端 TUN 写入(下行流量)
### 1.4 角色定义
| 角色 | 说明 |
|------|------|
| **Server** | LMVPN 服务端,监听 WebSocket,管理 TUN 网卡、IP 分配、包转发 |
| **Client** | LMVPN 客户端,连接服务端,维护本地 TUN 网卡,收发 IP 包 |
| **TUN** | 虚拟网卡,Linux 为 `tunN`macOS 为 `utunN`Windows 为 wintun |
---
## 2. 术语与消息分类
WebSocket 连接上的消息分为两类:
### 2.1 文本消息(Text Message
UTF-8 编码的 JSON 文本,用于**控制面**:认证、握手、错误通知。所有控制消息均含 `type` 字段标识类型。
涉及的 `type` 取值:`auth``auth_ok``auth_err``init``ready``error`
### 2.2 二进制消息(Binary Message
原始 IP 数据包(IPv4 或 IPv6),用于**数据面**。二进制帧**不是** JSON,不包含任何包装/封装头,就是裸 IP 包。
> ⚠️ 客户端不应向服务端发送既非合法 JSON 控制消息、也非合法 IP 包的二进制数据——服务端会解析失败并丢弃,但不会断开连接(`internal/vpn/tunnel.go:170-185`)。
---
## 3. 传输层规范
### 3.1 WebSocket 端点
| 项目 | 值 | 源码 |
|------|----|----|
| 路径 | `GET /ws` | `internal/router/router.go:15` |
| 协议 | WebSocketRFC 6455 | `internal/vpn/handler.go:32-39` |
| 子协议 | 无 | — |
| 读缓冲 | 4096 字节 | `internal/vpn/handler.go:17` |
| 写缓冲 | 4096 字节 | `internal/vpn/handler.go:18` |
### 3.2 Origin 校验
服务端对 WebSocket 升级请求的 `Origin` 头做如下校验(`internal/vpn/handler.go:19-29`):
- `Origin` 头**为空**:**放行**(非浏览器客户端场景)
- `Origin` 头**非空**:解析其 host 部分,**必须等于**请求本身的 host,否则拒绝升级
> 客户端实现建议:非浏览器环境可不发送 `Origin` 头,或发送与目标 host 一致的 Origin,避免被拒。
### 3.3 WSS / 反向代理
生产环境服务端通常仅监听 HTTP 或 Unix Socket`main.go:47-83`),由反向代理(如 Caddy/Nginx)提供 HTTPS/WSS 终结。客户端连接时应使用 `wss://` 协议并连接反向代理地址。
> 服务端默认监听 TCP `:8080` 与 Unix Socket `/run/lmvpnweb.sock``internal/config/config.go:32-46`),实际地址以部署为准。
---
## 4. 认证协议
客户端必须在 WebSocket 连接建立后完成认证,否则无法进入隧道握手阶段。认证方式二选一。
### 4.1 方式 AJWTquery 参数)
#### 4.1.1 获取 JWT
通过 HTTP 登录接口获取:
| 项目 | 值 | 源码 |
|------|----|----|
| 方法 | `POST` | `internal/router/router.go:17` |
| 路径 | `/api/login` | 同上 |
| 限流 | 5 次/分钟·IP(超出返回 `429` | `internal/middleware/ratelimit.go:75-86` |
**请求体**JSON):
```json
{ "username": "alice", "password": "secret" }
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `username` | string | 是 | 用户名 |
| `password` | string | 是 | 明文密码 |
**成功响应**`200``internal/handler/auth.go:21-30,74-82`):
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": 2, "username": "alice", "role": "user" }
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `token` | string | JWT,用于 WebSocket 认证 |
| `user.id` | uint | 用户 ID |
| `user.username` | string | 用户名 |
| `user.role` | string | 角色(`user` / `admin` |
**失败响应**
| HTTP 状态 | 场景 | 响应体 | 源码 |
|-----------|------|--------|------|
| `400` | 参数缺失 | `{"error":"请输入用户名和密码"}` | `auth.go:34-37` |
| `401` | 用户名不存在 / 密码错误 | `{"error":"用户名或密码错误"}` | `auth.go:40-48` |
| `403` | 账号已禁用(`status != 1` | `{"error":"账号已被禁用"}` | `auth.go:50-53` |
| `429` | 限流 | `{"error":"请求过于频繁,请稍后再试"}` | `ratelimit.go:79-81` |
| `500` | 生成令牌/会话失败 | `{"error":"生成令牌失败"}` 等 | `auth.go:57-72` |
#### 4.1.2 JWT 规格
| 项目 | 值 | 源码 |
|------|----|----|
| 签名算法 | HS256 | `internal/middleware/auth.go:43` |
| 有效期 | 24 小时 | `internal/middleware/auth.go:15,39` |
| 密钥来源 | 服务端配置(见 §12) | `internal/config/config.go:84-98` |
**Claims 字段**`internal/middleware/auth.go:23-29`):
| 字段 | 类型 | 说明 |
|------|------|------|
| `session_id` | string | 会话 IDUUID |
| `user_id` | uint | 用户 ID |
| `username` | string | 用户名 |
| `role` | string | 角色 |
| `exp` | numeric date | 过期时间(标准 JWT claim |
| `iat` | numeric date | 签发时间(标准 JWT claim |
#### 4.1.3 使用 JWT 连接
将 JWT 作为 query 参数附加到 WebSocket URL
```
ws://host:port/ws?token=<JWT>
wss://host/ws?token=<JWT>
```
服务端处理逻辑(`internal/vpn/handler.go:33,41-56`):
-`token` 参数非空,解析并验证 JWT
- 验证失败 → 发送 `{"type":"auth_err","message":"令牌无效或已过期"}` 并关闭连接
- 用户不存在或 `status != 1` → 发送 `{"type":"auth_err","message":"用户不存在或已禁用"}` 并关闭连接
- 验证通过 → **直接进入隧道握手**(无需再发送 `auth` 消息)
> ℹ️ **JWT 路径不触发密码认证限流**。密码认证限流仅作用于方式 B。
### 4.2 方式 B:用户名/密码(首条消息)
连接 WebSocket 后(不带 `token` 参数),客户端须**立即**发送一条文本消息完成认证。
#### 4.2.1 认证消息
```json
{ "type": "auth", "username": "alice", "password": "secret" }
```
| 字段 | 类型 | 必填 | 说明 | 源码 |
|------|------|------|------|------|
| `type` | string | 是 | 固定值 `"auth"` | `internal/vpn/auth.go:17-21,35` |
| `username` | string | 是 | 用户名 | 同上 |
| `password` | string | 是 | 明文密码 | 同上 |
> ⚠️ 这必须是连接建立后客户端发送的**第一条**消息。服务端读取的第一条消息若不是合法的 `auth` JSON,连接将被关闭(`internal/vpn/auth.go:29-39`)。
#### 4.2.2 认证响应
| 响应 | 含义 | 源码 |
|------|------|------|
| `{"type":"auth_ok"}` | 认证成功,进入隧道握手 | `internal/vpn/auth.go:61-65` |
| `{"type":"auth_err","message":"..."}` | 认证失败,随后服务端关闭连接 | `internal/vpn/auth.go:36,43,50,56` |
`auth_err``message` 可能取值见 [§9.2](#92-auth_err-文案表)。
#### 4.2.3 密码认证限流
| 项目 | 值 | 源码 |
|------|----|----|
| 限流键 | `客户端IP + ":" + 用户名` | `internal/vpn/auth.go:41` |
| 阈值 | 5 次/分钟 | `internal/vpn/auth.go:15` |
| 触发后行为 | 发送 `auth_err` "认证尝试过于频繁,请稍后再试" 并关闭连接 | `internal/vpn/auth.go:42-46` |
> 注意:限流在**读取并解析**到合法 `auth` 消息后才检查。若首条消息不是合法 JSON 或 `type != "auth"`,直接关闭连接,不消耗限流配额(`internal/vpn/auth.go:35-39`)。
### 4.3 用户状态要求
无论哪种认证方式,用户必须满足 `status = 1`(启用状态)。被禁用的用户(`status != 1`)无法认证通过(`internal/vpn/auth.go:49``internal/vpn/handler.go:49`)。
### 4.4 认证流程图
```mermaid
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: WebSocket 升级请求 GET /ws[?token=JWT]
S-->>C: 升级成功 (101)
alt 方式 A: JWT
S->>S: 解析验证 JWT
alt 验证失败/用户禁用
S-->>C: {"type":"auth_err","message":"..."}
S--xC: 关闭连接
else 验证通过
S->>S: 进入隧道握手
end
else 方式 B: 用户名/密码
C->>S: 文本: {"type":"auth","username":"","password":""}
S->>S: 检查限流 / 校验凭据
alt 失败
S-->>C: {"type":"auth_err","message":"..."}
S--xC: 关闭连接
else 成功
S-->>C: {"type":"auth_ok"}
S->>S: 进入隧道握手
end
end
```
---
## 5. 隧道握手协议
认证通过后,服务端进入 `runTunnel` 流程(`internal/vpn/tunnel.go:83`),执行前置检查、IP 分配、发送 `init`,并等待客户端 `ready`
### 5.1 前置检查
服务端在分配 IP 前依次检查,任一失败则发送 `error` 消息并**关闭连接**(不进入握手):
| 检查项 | 失败消息 | 源码 |
|--------|----------|------|
| VPN 服务已启用(`VPN.Running()` | `{"type":"error","message":"VPN 服务未启用"}` | `internal/vpn/tunnel.go:86-89` |
| 该用户连接数 < 3 | `{"type":"error","message":"连接数已达上限"}` | `internal/vpn/tunnel.go:91-97` |
| IP 分配成功 | `{"type":"error","message":"分配 IP 失败: <原因>"}` | `internal/vpn/tunnel.go:109-113` |
### 5.2 IP 分配规则
服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66``internal/vpn/service.go:47-58`):
| 地址 | 分配规则 | 说明 |
|------|----------|------|
| 子网第 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`
#### IPv6 双栈
当服务端配置了 IPv6 子网(`Subnet6`)时,客户端同时获得 IPv4 和 IPv6 地址:
- IPv4 地址始终分配(`Subnet` 必填)
- IPv6 地址仅当 `Subnet6` 非空时分配(可选)
- IPv6 预留独立于 IPv4 预留,可单独配置
- IPv6 子网前缀限制:`/64` ~ `/126`
- 对于 `/64` 等大子网,`cidr.AddressCount` 会溢出,实际扫描上限为 65536 个地址
### 5.3 init 消息
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:132-148`):
```json
{
"type": "init",
"ip": "10.0.0.5",
"prefix": 24,
"mtu": 1420,
"server_ip": "10.0.0.1",
"ip6": "fd00:dead:beef::5",
"prefix6": 112,
"server_ip6": "fd00:dead:beef::1"
}
```
| 字段 | 类型 | 必填 | 说明 | 源码 |
|------|------|------|------|------|
| `type` | string | 是 | 固定值 `"init"` | `internal/vpn/protocol.go:3-10` |
| `ip` | string | 是 | 分配给客户端的 IPv4 地址 | `tunnel.go:136` |
| `prefix` | int | 是 | IPv4 子网前缀长度(如 `24` | `tunnel.go:137` |
| `mtu` | int | 是 | TUN 网卡 MTU | `tunnel.go:138` |
| `server_ip` | string | 是 | 服务器 IPv4 地址 | `tunnel.go:139` |
| `ip6` | string | 否 | 分配给客户端的 IPv6 地址(仅当服务端配置了 IPv6 子网时存在) | `tunnel.go:141-144` |
| `prefix6` | int | 否 | IPv6 子网前缀长度 | `tunnel.go:142` |
| `server_ip6` | string | 否 | 服务器 IPv6 地址 | `tunnel.go:143` |
> ️ `ip6`/`prefix6`/`server_ip6` 字段使用 `omitempty`,旧客户端可安全忽略。若服务端未配置 IPv6 子网,这三个字段不会出现。
### 5.4 ready 消息与超时
客户端收到 `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-126`):
- **IPv4 包**:源地址必须等于客户端被分配的 IPv4 地址(`init.ip`
- **IPv6 包**:源地址必须等于客户端被分配的 IPv6 地址(`init.ip6`
不匹配的包将被静默丢弃(不转发、不写入 TUN、不断开连接)。
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址与 `init.ip` / `init.ip6` 匹配的 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 子网约束
| 约束 | 值 | 源码 |
|------|----|----|
| IPv4 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
| IPv4 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
| IPv6 前缀长度 | /64 ~ /126 | `internal/handler/vpn.go:81-92` |
| 可用容量 | `AddressCount - 3` | `internal/vpn/alloc.go:106-112` |
| IPv6 大子网容量 | ~65533/64 等大子网 AddressCount 溢出时) | `internal/vpn/alloc.go:108-110` |
### 8.4 MTU 约束
| 约束 | 值 | 源码 |
|------|----|----|
| 范围 | 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-10`):
```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"`
IP6 string `json:"ip6,omitempty"`
Prefix6 int `json:"prefix6,omitempty"`
ServerIP6 string `json:"server_ip6,omitempty"`
}
```
### 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":"...","ip6":"...","prefix6":112,"server_ip6":"..."}` |
| `error` | Text | 握手阶段失败 | JSON | `{"type":"error","message":"..."}` |
| 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) |
| Ping | WebSocket Ping | 每 30 秒 | 空 | WebSocket 协议层) |
---
## 11. TUN 接口配置说明
客户端收到 `init` 后须自行创建并配置 TUN 网卡。本节给出各平台的配置规范(仅规范,不含完整代码)。
### 11.1 通用配置项
| 配置项 | 取值来源 | 说明 |
|--------|----------|------|
| TUN 网卡地址 (IPv4) | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` |
| TUN 网卡地址 (IPv6) | `init.ip6` / `init.prefix6` | 如 `fd00:dead:beef::5/112`(可选,仅当 init 含 ip6 时) |
| MTU | `init.mtu` | 如 `1420` |
| 对端地址 / 默认路由网关 (IPv4) | `init.server_ip` | 如 `10.0.0.1` |
| 对端地址 (IPv6) | `init.server_ip6` | 如 `fd00:dead:beef::1`(可选) |
### 11.2 Linux
参考服务端实现(`internal/vpn/tun_linux.go`):
```
# 创建 TUN(通常由库完成,如 songgao/water
ip link set dev <tun> up
ip addr add dev <tun> <ip>/<prefix> peer <server_ip>
ip link set dev <tun> mtu <mtu>
```
IPv6(仅当 init 含 `ip6` 时):
```
ip addr add dev <tun> <ip6>/<prefix6>
```
路由:若需将所有流量经 VPN,添加默认路由:
```
ip route add 0.0.0.0/0 dev <tun>
ip route add ::/0 dev <tun> # IPv6 默认路由(可选)
```
### 11.3 macOS
参考服务端实现(`internal/vpn/tun_darwin.go`)。macOS 的 TUN 设备名为 `utunN`,使用 `ifconfig`
```
ifconfig <utun> inet <ip>/<prefix> <server_ip> up
ifconfig <utun> mtu <mtu>
```
IPv6(仅当 init 含 `ip6` 时):
```
ifconfig <utun> inet6 <ip6>/<prefix6> up
```
路由:
```
route add -inet -net 0.0.0.0/0 -interface <utun>
route add -inet6 -net ::/0 -interface <utun> # IPv6 默认路由(可选)
```
> macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。
### 11.4 Windows
Windows 无原生 TUN,需使用 **wintun** 驱动。客户端须:
1. 加载 wintun.dll
2. 创建 wintun 适配器
3. 设置 IP 地址、前缀、MTU
4. 配置路由
具体 API 参考 wintun 官方文档,本文档不展开。
### 11.5 关于 `DoRemoteIPConfig`
服务端配置中有 `DoRemoteIPConfig` 字段(`internal/model/vpn.go:15`),原意为"由服务端远程配置客户端 TUN"。但当前隧道实现中**未使用**该字段——客户端始终需要根据 `init` 消息**自行配置** TUN 网卡。客户端开发者不应假设服务端会代为配置。
### 11.6 权限要求
创建和配置 TUN 网卡需要提升权限:
| 平台 | 权限 |
|------|------|
| Linux | root 或 `CAP_NET_ADMIN` 能力 |
| macOS | rootsudo |
| Windows | 管理员权限 |
---
## 12. 服务端配套依赖
客户端能否通过 VPN 上网,取决于服务端的网络配置。客户端开发者应了解以下服务端前提(用于排查问题)。
### 12.1 IP 转发
Linux 服务端须开启 IP 转发(`internal/vpn/diag_linux.go:53-60`):
```
sysctl -w net.ipv4.ip_forward=1
```
IPv6 双栈场景还须开启 IPv6 转发(`internal/vpn/diag_linux.go:116-122`):
```
sysctl -w net.ipv6.conf.all.forwarding=1
```
### 12.2 NAT 伪装
服务端须配置 NAT masquerade,将客户端源 IP 转换为服务器物理网卡 IP(`internal/vpn/diag_linux.go:80-113`)。
**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
```
IPv6 NAT66(仅双栈场景需要):
**nft**
```
nft add rule inet lmvpn_nat postrouting oifname <物理网卡> ip6 saddr <VPN_V6_SUBNET> masquerade
```
**ip6tables(回退)**
```
ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERADE
```
> 服务端诊断接口 `GET /api/admin/vpn/diag``internal/handler/vpn.go:190-192`)会检测上述配置(含 IPv6),客户端无法上网时可请管理员查看诊断结果。
### 12.3 子网与 MTU
由管理员通过 `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` | 30(可配置) | 单用户并发连接上限 | `internal/model/vpn.go` (`VpnSetting.MaxConnsPerUser`) |
| `tokenExpire` | 24h | JWT 有效期 | `internal/middleware/auth.go:15` |
| 登录限流 | 5/min·IP | `/api/login` 限流 | `internal/middleware/ratelimit.go:76` |
| 密码认证限流 | 5/min·(IP+用户名) | WebSocket 密码认证限流 | `internal/vpn/auth.go:15,41` |
| MTU 范围 | 50065535 | 配置校验范围 | `internal/handler/vpn.go:131` |
| MTU 默认 | 1420 | 数据库默认值 | `internal/model/vpn.go:11` |
| 子网前缀上限 | /30 | 配置校验 | `internal/handler/vpn.go:75` |
| WebSocket 读缓冲 | 4096 B | — | `internal/vpn/handler.go:17` |
| WebSocket 写缓冲 | 4096 B | — | `internal/vpn/handler.go:18` |
---
## 附录 B:源码文件索引
客户端开发者重点阅读的服务端源码文件:
| 文件 | 内容 |
|------|------|
| `internal/vpn/handler.go` | WebSocket 升级、认证入口、JWT 处理 |
| `internal/vpn/auth.go` | 密码认证逻辑、限流 |
| `internal/vpn/tunnel.go` | 隧道主循环、握手、心跳、数据收发 |
| `internal/vpn/protocol.go` | 控制消息结构定义(`init`/`controlMessage` |
| `internal/vpn/switch.go` | 包转发路由判定、反欺骗、C2C 开关 |
| `internal/vpn/alloc.go` | IP 分配与预留逻辑 |
| `internal/vpn/service.go` | VPN 服务管理、TUN 读写循环 |
| `internal/vpn/tun_linux.go` | Linux TUN 配置(客户端配置可参考) |
| `internal/vpn/tun_darwin.go` | macOS TUN 配置(客户端配置可参考) |
| `internal/middleware/auth.go` | JWT 生成与解析、Claims 结构 |
| `internal/handler/auth.go` | 登录接口 `POST /api/login` |
| `internal/handler/vpn.go` | VPN 设置/状态/预留管理 API |
| `internal/router/router.go` | 路由表(`/ws``/api/*` |
| `internal/model/vpn.go` | VPN 设置数据模型 |
| `internal/model/user.go` | 用户数据模型 |
| `internal/config/config.go` | 服务端配置结构 |