Compare commits

..
15 Commits
Author SHA1 Message Date
kevin a98cdb0cac fix: 禁止管理员通过 UpdateUser 禁用自己的账号 2026-07-10 14:22:16 +08:00
kevin 9fda231d47 fix: 修复踢下线竞态窗口,先禁用再踢线防重连
KickUserClient 原先先踢线再禁用账号,客户端在两步之间自动重连时
status 仍为 1 可成功认证。调换顺序为先禁用+作废会话再踢线,确保
重连时认证已被拦截。
2026-07-10 14:21:59 +08:00
kevin 50e1661c1b feat: 实现每用户流量统计与定时落库
流量统计从全站按日聚合升级为 per-user 维度,并引入 60 秒定时
落库机制,服务崩溃最多丢失 60 秒数据。

后端:
- model/vpn.go: 新增 UserTrafficStat 模型(user_id + date 联合唯一索引)
- db/db.go: AutoMigrate 注册新模型
- vpn/tunnel.go: tunnelConn 增加 flushedRx/flushedTx 快照字段 +
  flushDelta() 增量计算;recordTraffic 改为 per-user 双写
  (user_traffic_stats + traffic_stats);连接断开 defer 改增量落库
- vpn/service.go: 新增 flushDone 字段 + trafficFlusher(60s 定时
  落库)+ flushAllTraffic;Stop() 先停 flusher 再最终 flush 全部
  在线连接增量;TotalLiveTraffic 改返回未落库增量避免与 DB 重复;
  新增 UserLiveTraffic(userID);ClientInfo 增加 RxBytes/TxBytes
- handler/traffic.go: 新建 5 个 handler(admin 3 个 + user 2 个)
- router.go: 注册 5 条新路由

新增 API:
- GET /api/admin/traffic/today  所有用户今日流量排行
- GET /api/admin/traffic/history?days=N  全站近 N 天流量历史
- GET /api/admin/traffic/users/:id?days=N  指定用户近 N 天流量
- GET /api/me/traffic/today  自己的今日流量
- GET /api/me/traffic?days=N  自己的近 N 天流量

前端:
- 安装 chart.js + vue-chartjs
- 新建 TrafficChart.vue 可复用柱状图组件(上行/下行双柱)
- AdminView.vue: 在线客户端表格增加 RX/TX 列 + 全站 7 天流量
  图表 + 用户今日流量排行表
- ProfileView.vue: 新增流量统计卡片(今日上行/下行/合计)+ 7 天
  流量柱状图
- zh.ts/en.ts: 新增 traffic 相关国际化
2026-07-10 14:18:06 +08:00
kevin aad8fa9848 feat: 用户个人页面展示当前 VPN 连接信息
- handler/vpn.go: 新增 GetMyVpnConnections,返回当前用户的在线
  VPN 连接列表(IP/连接时间)及 max_conns_per_user 上限
- router.go: 注册 GET /api/me/vpn/connections(普通用户可访问)
- ProfileView.vue: 新增"我的 VPN 连接"区块,含 n/max 徽章、
  IPv4/IPv6/连接时间表格,底部提示异常连接请修改密码
- zh.ts/en.ts: 新增 myVpnConnections/noConnections/
  abnormalConnectionHint 国际化
2026-07-10 13:53:49 +08:00
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
kevin f6a1fb6288 fix: 踢下线同时禁用账号防重连,禁止管理员踢自己
- handler/vpn.go: KickUserClient 踢线后设置 status=0 并失效所有会话,
  阻止客户端自动重连;新增自检,目标为当前管理员时返回 400
- AdminView.vue: handleKick 调用 API 前自检,自己则提示去修改密码
- zh.ts/en.ts: 新增 cannotKickSelf,更新 confirmKick 提示含禁用账号
2026-07-10 13:33:04 +08:00
kevin f94aa3c4ac refactor: 在线客户端卡片移至管理后台首页,移除 body 退出按钮
- AdminView.vue: 新增 fetchVpnStatus/handleKick,用户信息卡片下方
  插入在线客户端表格(含踢下线按钮),30s 轮询同步刷新;
  删除 body 退出登录按钮及 handleLogout(header 已有退出入口)
- VpnView.vue: 移除在线客户端卡片及 handleKick(已迁移至 AdminView)
2026-07-10 13:25:55 +08:00
kevin a170a234b2 fix: 管理员修改用户信息后断开对应 VPN 连接
UpdateUser 在改密/改角色/禁用用户时,失效 Web 会话的同时调用
KickUser 断开 VPN 隧道,与 ChangePassword 行为对齐。
2026-07-10 13:17:45 +08:00
kevin b8720f413d fix: 自助修改密码后断开 VPN 连接并登出所有 Web 会话
- auth.go: ChangePassword 失效所有会话(不再排除当前会话),调用 KickUser 断开 VPN 隧道
- ProfileView.vue: 改密成功后 logout 清除 token,跳转登录页并携带 msg 参数
- LoginView.vue: 读取 query.msg 显示"密码已修改,请重新登录"绿色提示
- zh.ts/en.ts: 新增 login.passwordChanged 国际化 key
2026-07-10 13:11:46 +08:00
kevin fea3fc62f7 feat: 新增管理员踢下线 VPN 客户端功能
- vpn/service.go: ClientInfo 增加 user_id 字段,新增 KickUser 方法
  用读锁收集目标连接后逐个关闭 WebSocket,返回断开数量
- vpn/tunnel.go: info() 填充 UserID
- handler/vpn.go: 新增 KickUserClient handler,校验用户存在后调用 KickUser
- router.go: 注册 DELETE /api/admin/vpn/clients/:id
- VpnView.vue: 在线客户端表格新增操作列与踢下线按钮,handleKick 确认后调用 API 并刷新
- zh.ts/en.ts: 新增 kick/confirmKick/kickFailed 国际化 key
2026-07-10 13:01:26 +08:00
kevin 129c6c7a96 fix: 密码长度上限校验 + 请求体大小限制防 DoS
- 新增 validatePassword 校验函数(6-72 字节,bcrypt 硬上限),统一应用于 ChangePassword/CreateUser/UpdateUser,超长密码返回清晰提示而非迷惑性的"密码加密失败"
- 新增 BodyLimit 中间件(http.MaxBytesReader),对所有 /api JSON 端点限制 1 MiB 请求体,防止内存耗尽型 DoS;/ws 走连接劫持不受影响
2026-07-09 15:51:10 +08:00
kevin 195769534f docs: add MIT License 2026-07-09 14:53:12 +08:00
kevin eb5d79f81e 修改默认socket路径 2026-07-09 12:36:58 +08:00
kevin 098c601687 docs: update README for dynamic firewall config and UFW support
- Remove hardcoded VPN_SUBNET/VPN_SUBNET6 variables section
- Add firewall auto-management section explaining dynamic NAT/UFW config
- Update script flow to reflect removal of manual NAT config
- Fold manual NAT config into collapsible details (usually not needed)
- Add UFW to environment requirements and diag panel description
- Update troubleshooting table with UFW-related issues
- Update vpn/ directory description to include firewall management
2026-07-08 22:13:58 +08:00
kevin 4b82340a9f fix: dynamic firewall config + UFW support + anti-spoof bug fix
Root cause: UFW FORWARD policy DROP overrides lmvpn_nat accept rules.
DNS (small UDP) worked but TCP packets were silently dropped.

- Add firewall_linux.go: dynamic NAT/forward/UFW config from ApplySettings
- Add firewall_darwin.go/firewall_other.go: stubs
- Fix anti-spoof bug in switch.go: return dropPacket sentinel instead of
  nil, so spoofed packets are no longer written to TUN
- Simplify install_linux.sh: remove hardcoded subnets and NAT config
- Add UFW detection to diag panel
2026-07-08 20:11:15 +08:00
33 changed files with 1318 additions and 361 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 wuwenfengmi1998
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42 -20
View File
@@ -2,7 +2,7 @@
基于 WebSocket 隧道与 TUN 虚拟网卡的轻量级三层 VPN 系统,采用 Go + Vue 3 构建。服务端通过 WebSocket 与客户端建立控制通道,TUN 网卡与 WebSocket 之间双向搬运原始 IP 数据包,实现点对点与点对站点网络连接。
> **平台说明**:服务端部署**目前仅在 Linux 下测试功能正常**systemd + nftables/iptables NAT)。配套客户端见 [lmvpn_client](https://github.com/wuwenfengmi1998/lmvpn_client),客户端协议规范见 [`docs/client-development.md`](docs/client-development.md)。
> **平台说明**:服务端部署**目前仅在 Linux 下测试功能正常**systemd + nftables/iptables NAT,自动兼容 UFW)。配套客户端见 [lmvpn_client](https://github.com/wuwenfengmi1998/lmvpn_client),客户端协议规范见 [`docs/client-development.md`](docs/client-development.md)。
---
@@ -19,6 +19,7 @@
- [目录说明](#目录说明)
- [常见问题排查](#常见问题排查)
- [相关文档](#相关文档)
- [许可证](#许可证)
---
@@ -28,14 +29,14 @@
- **Go**:≥ 1.26.4
- **Node.js**:≥ 22.18(用于构建前端)
- **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward`
- **防火墙工具**nftables(推荐)或 iptables
- **防火墙工具**nftables(推荐)或 iptables,兼容 UFW
- **网络**:公网 IP,开放 Web 端口(或经反向代理)
---
## 一键部署(Linux,推荐)
仓库自带 `install_linux.sh`,一条命令完成构建、部署、网络配置与 systemd 服务安装。
仓库自带 `install_linux.sh`,一条命令完成构建、部署、内核转发配置与 systemd 服务安装。NAT / 转发 / UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置。
```bash
git clone <仓库地址> lmvpn_server
@@ -51,22 +52,19 @@ sudo bash install_linux.sh
4. 创建无 shell 的系统用户 `lmvpn`
5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn`
6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf`
7. 配置 NAT 与转发规则:自动检测出口网卡,优先 nft,回退 iptables
8. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
9. 启动服务并打印状态
7. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
8. 启动服务(NAT / 转发 / UFW 规则由程序在启动时根据后台子网自动配置)
> ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。
### 子网变量
### 防火墙规则自动管理
脚本顶部有两个变量需与后台 VPN 设置保持一致
NAT masquerade、forward 放行、UFW 转发规则由服务端程序在 `ApplySettings()` 时根据当前后台 VPN 子网动态配置
```bash
VPN_SUBNET="192.168.77.0/24" # IPv4 子网
VPN_SUBNET6="fd00:dead:beef::/112" # IPv6 子网,留空则不配置 IPv6 NAT
```
若在管理后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 NAT 规则,否则客户端无法上网。
- 自动检测出口网卡(`ip route show default`
- 配置 nft `lmvpn_nat` 表的 postrouting masquerade 和 forward accept 规则
- 检测 UFW 是否启用(存在 `ufw-user-forward` 链),若启用则自动创建 `lmvpn-fwd` / `lmvpn6-fwd` 链并注入 jump
- 在后台修改子网后保存即可,程序自动更新规则,无需重新执行脚本
---
@@ -90,7 +88,7 @@ VPN_SUBNET6="fd00:dead:beef::/112" # IPv6 子网,留空则不配置 IPv6 NAT
4. **启用 VPN**:进入「管理后台 → VPN 管理」,确认子网(默认 `192.168.77.0/24` + IPv6 `fd00:dead:beef::/112`),打开「启用」并保存。
5. **诊断检查**:VPN 管理页的「系统环境检测」面板(对应 `GET /api/admin/vpn/diag`)会检测 ip_forward、NAT、TUN 等,客户端无法上网时优先查看此处。
5. **诊断检查**:VPN 管理页的「系统环境检测」面板(对应 `GET /api/admin/vpn/diag`)会检测 ip_forward、NAT、UFW 转发规则、TUN 等,客户端无法上网时优先查看此处。
---
@@ -134,7 +132,12 @@ EOF
sudo sysctl -p /etc/sysctl.d/99-lmvpn.conf
```
### 4. 配置 NATnft 示例)
### 4. 安装 systemd 服务
> NAT / 转发 / UFW 规则由服务端程序在启动时自动配置,无需手动设置。以下命令仅在程序无法自动配置防火墙时作为参考。
<details>
<summary>手动配置 NAT(点击展开,通常不需要)</summary>
将 `WAN_IFACE` 替换为出口网卡,`VPN_SUBNET` 替换为 VPN 子网:
@@ -152,6 +155,17 @@ sudo nft add rule inet lmvpn_nat forward ip saddr "$VPN_SUBNET" accept
sudo nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept
```
若启用了 UFW,还需放行 VPN 子网转发:
```bash
sudo ufw route allow from "$VPN_SUBNET"
sudo ufw route allow to "$VPN_SUBNET"
sudo ufw route allow from "$VPN_SUBNET6"
sudo ufw route allow to "$VPN_SUBNET6"
```
</details>
### 5. 安装 systemd 服务
```bash
@@ -331,7 +345,7 @@ sudo bash install_linux.sh
- `middleware/` — 中间件(认证、限流)
- `model/` — 数据模型
- `router/` — 路由
- `vpn/` VPN 核心(认证、隧道、TUN、包转发)
- `vpn/` - VPN 核心(认证、隧道、TUN、包转发、防火墙自动配置
- `docs/` — 文档
- `pytest/` — Python 测试脚本
- `install_linux.sh` — Linux 一键部署脚本
@@ -344,10 +358,10 @@ sudo bash install_linux.sh
| 现象 | 可能原因 | 排查建议 |
|------|----------|----------|
| 服务启动失败 | socket 目录不可写 / 端口占用 | `journalctl -u lmvpn`;设 `sock: ""` 或换可写目录 |
| 客户端连上但无法上网 | 未开 ip_forward / 未配 NAT / 子网不一致 | 管理后台诊断面板;核对脚本 `VPN_SUBNET` 与后台设置 |
| 客户端连上但无法上网 | 未开 ip_forward / NAT 未生效 / UFW 拦截 | 管理后台诊断面板查看 UFW 状态和 NAT 检测结果 |
| 客户端连上但下行极慢(几十 bps) | UFW FORWARD 默认 DROP 拦截 TCP 包 | 诊断面板检查 UFW 转发规则;重启服务使程序自动配置 UFW |
| 登录提示「请求过于频繁」 | 触发限流(`/api/login` 5 次/分钟·IP) | 等待 1 分钟后重试 |
| 修改子网后客户端异常 | NAT 规则仍是旧子网 | 同步脚本变量并重跑 `install_linux.sh` |
| WebSocket 频繁断开 | 反代未透传 `Upgrade`/`Connection` 头 | 检查反代 `/ws` 配置;调大读超时 |
| 修改子网后客户端异常 | 旧防火墙规则残留 | 后台重新保存设置,程序自动更新 NAT / UFW 规则 |
| 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 |
---
@@ -365,3 +379,11 @@ sudo bash install_linux.sh
- `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟
- WebSocket `/ws` 支持 JWT`?token=xxx`)与用户名/密码两种认证
- 生产环境务必经反向代理启用 HTTPS/WSS
---
## 许可证
本项目基于 [MIT License](LICENSE) 开源。
Copyright (c) 2026 wuwenfengmi1998
+1 -1
View File
@@ -780,7 +780,7 @@ ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERA
| `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` |
| `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` |
+30
View File
@@ -8,8 +8,10 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-chartjs": "^5.3.4",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
@@ -625,6 +627,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1734,6 +1742,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -3405,6 +3425,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-i18n": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
+2
View File
@@ -11,8 +11,10 @@
"type-check": "vue-tsc --build"
},
"dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-chartjs": "^5.3.4",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const { t } = useI18n()
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const props = defineProps<{
records: TrafficRecord[]
}>()
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
const chartData = computed(() => ({
labels: props.records.map(r => r.date.slice(5)),
datasets: [
{
label: t('traffic.upload'),
data: props.records.map(r => r.rx_bytes),
backgroundColor: 'rgba(14, 165, 233, 0.6)',
borderColor: 'rgba(14, 165, 233, 1)',
borderWidth: 1,
},
{
label: t('traffic.download'),
data: props.records.map(r => r.tx_bytes),
backgroundColor: 'rgba(34, 197, 94, 0.6)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 1,
},
],
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
return `${label}: ${formatBytes(context.parsed.y)}`
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => formatBytes(Number(value)),
},
},
},
}
</script>
<template>
<div class="h-64">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+22 -1
View File
@@ -45,6 +45,7 @@ export default {
loginFailed: 'Login failed',
loggingIn: 'Logging in...',
loginButton: 'Login',
passwordChanged: 'Password changed, please log in again',
},
profile: {
title: 'Profile',
@@ -58,6 +59,9 @@ export default {
enterOldAndNewPassword: 'Please enter current and new password',
passwordsDoNotMatch: 'The two passwords do not match',
passwordChangeFailed: 'Failed to change password',
myVpnConnections: 'My VPN Connections',
noConnections: 'No active connections',
abnormalConnectionHint: 'If you notice any abnormal connections, please change your password to disconnect all devices.',
},
about: {
title: 'About LmVPN',
@@ -91,6 +95,18 @@ export default {
'Are you sure you want to delete user {username}? This action cannot be undone.',
confirmDeleteButton: 'Confirm Delete',
},
traffic: {
myTraffic: 'My Traffic',
userTrafficToday: "User Traffic Today",
todayTraffic: "Today's Traffic",
upload: 'Upload',
download: 'Download',
total: 'Total',
history: 'Traffic History',
date: 'Date',
noTrafficData: 'No traffic data',
trafficHistory7d: 'Traffic - Last 7 Days',
},
vpn: {
title: 'VPN Management',
refresh: 'Refresh',
@@ -132,6 +148,7 @@ export default {
serverConfigTunIp: 'Server Configures TUN IP',
autoConfig: 'Auto',
manual: 'Manual',
maxConnsPerUser: 'Max Connections Per User',
saveSettings: 'Save Settings',
saveSuccess: 'Saved successfully',
user: 'User',
@@ -148,6 +165,10 @@ export default {
ipv6Address: 'IPv6 Address (optional)',
selectUserAndIp: 'Please select a user and fill in at least one IP address',
confirmDeleteReservation: 'Delete this reservation?',
kick: 'Kick',
confirmKick: 'Disconnect all VPN connections for user {username} and disable their account?',
kickFailed: 'Failed to kick user',
cannotKickSelf: 'Cannot kick yourself. To disconnect your own devices, please change your password.',
},
footer: {
lastCommit: 'Last commit',
@@ -163,7 +184,7 @@ export default {
'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.',
multiDevice: 'Multi-Device Concurrent',
multiDeviceDesc:
'Up to 3 concurrent connections per user with automatic tunnel IP assignment.',
'Supports multi-device concurrent connections per user, configurable in admin panel.',
dualStack: 'IPv4/IPv6 Dual-Stack',
dualStackDesc:
'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.',
+22 -1
View File
@@ -45,6 +45,7 @@ export default {
loginFailed: '登录失败',
loggingIn: '登录中...',
loginButton: '登录',
passwordChanged: '密码已修改,请重新登录',
},
profile: {
title: '用户信息',
@@ -58,6 +59,9 @@ export default {
enterOldAndNewPassword: '请填写原密码和新密码',
passwordsDoNotMatch: '两次输入的新密码不一致',
passwordChangeFailed: '密码修改失败',
myVpnConnections: '我的 VPN 连接',
noConnections: '暂无在线连接',
abnormalConnectionHint: '如发现异常连接,请修改密码以断开所有设备',
},
about: {
title: '关于 LmVPN',
@@ -90,6 +94,18 @@ export default {
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
confirmDeleteButton: '确认删除',
},
traffic: {
myTraffic: '我的流量统计',
userTrafficToday: '用户今日流量',
todayTraffic: '今日流量',
upload: '上行',
download: '下行',
total: '合计',
history: '流量历史',
date: '日期',
noTrafficData: '暂无流量数据',
trafficHistory7d: '近 7 天流量',
},
vpn: {
title: 'VPN 管理',
refresh: '刷新',
@@ -131,6 +147,7 @@ export default {
serverConfigTunIp: '服务端配置 TUN IP',
autoConfig: '自动配置',
manual: '手动',
maxConnsPerUser: '每用户最大连接数',
saveSettings: '保存设置',
saveSuccess: '保存成功',
user: '用户',
@@ -147,6 +164,10 @@ export default {
ipv6Address: 'IPv6 地址 (可选)',
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
confirmDeleteReservation: '确认删除该预留?',
kick: '踢下线',
confirmKick: '确定要断开用户 {username} 的所有 VPN 连接并禁用其账号吗?',
kickFailed: '踢下线失败',
cannotKickSelf: '不能踢自己下线,如需断开自己的设备请修改密码',
},
footer: {
lastCommit: '最后提交',
@@ -160,7 +181,7 @@ export default {
userManageDesc:
'管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。',
multiDevice: '多设备并发',
multiDeviceDesc: '每用户最多 3 个并发连接,自动分配隧道 IP 地址。',
multiDeviceDesc: '每用户支持多设备并发连接,可在管理后台配置上限。',
dualStack: 'IPv4/IPv6 双栈',
dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。',
reservation: '静态 IP 预留',
+165 -12
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import TrafficChart from '@/components/TrafficChart.vue'
const router = useRouter()
const authStore = useAuthStore()
@@ -19,6 +20,41 @@ const stats = ref([
const userCount = ref<number | null>(null)
let statsTimer: ReturnType<typeof setInterval> | null = null
interface ClientInfo {
user_id: number
username: string
ip: string
ip6?: string
connected_at: string
rx_bytes: number
tx_bytes: number
}
const vpnClients = ref<ClientInfo[]>([])
const kickError = ref('')
interface UserTraffic {
user_id: number
username: string
rx_bytes: number
tx_bytes: number
total_bytes: number
}
const userTrafficToday = ref<UserTraffic[]>([])
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const siteTraffic7d = ref<TrafficRecord[]>([])
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
function formatUptime(seconds: number): string {
if (seconds <= 0) return '0m'
const d = Math.floor(seconds / 86400)
@@ -61,11 +97,71 @@ async function fetchStats() {
} catch {}
}
async function fetchVpnStatus() {
try {
const res = await fetch('/api/admin/vpn/status', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
vpnClients.value = data.clients || []
} catch {}
}
async function fetchTrafficToday() {
try {
const res = await fetch('/api/admin/traffic/today', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
userTrafficToday.value = (data.users || []).sort((a: UserTraffic, b: UserTraffic) => b.total_bytes - a.total_bytes)
} catch {}
}
async function fetchSiteTraffic7d() {
try {
const res = await fetch('/api/admin/traffic/history?days=7', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
siteTraffic7d.value = data.records || []
} catch {}
}
async function handleKick(userId: number, username: string) {
kickError.value = ''
if (userId === authStore.user?.id) {
kickError.value = t('vpn.cannotKickSelf')
return
}
if (!confirm(t('vpn.confirmKick', { username }))) return
try {
const res = await fetch(`/api/admin/vpn/clients/${userId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.token}` },
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('vpn.kickFailed'))
await fetchVpnStatus()
} catch (e: any) {
kickError.value = e.message
}
}
onMounted(async () => {
await authStore.fetchUser()
fetchUserCount()
fetchStats()
statsTimer = setInterval(fetchStats, 30000)
fetchVpnStatus()
fetchTrafficToday()
fetchSiteTraffic7d()
statsTimer = setInterval(() => {
fetchStats()
fetchVpnStatus()
fetchTrafficToday()
}, 30000)
})
onUnmounted(() => {
@@ -75,11 +171,6 @@ onUnmounted(() => {
function handleStatClick(route: string) {
if (route) router.push(route)
}
function handleLogout() {
authStore.logout()
router.push('/')
}
</script>
<template>
@@ -118,11 +209,73 @@ function handleLogout() {
</div>
</div>
<button
class="px-6 py-2.5 rounded-lg font-medium text-white bg-red-500 hover:bg-red-600 transition-colors"
@click="handleLogout"
>
{{ t('admin.logoutButton') }}
</button>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!vpnClients.length">
<td colspan="7" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
</tr>
<tr v-for="(c, i) in vpnClients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.rx_bytes) }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.tx_bytes) }}</td>
<td class="px-6 py-3">
<button
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors"
@click="handleKick(c.user_id, c.username)"
>
{{ t('vpn.kick') }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.trafficHistory7d') }}</h3>
<TrafficChart :records="siteTraffic7d" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('traffic.userTrafficToday') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.username') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.total') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!userTrafficToday.length">
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('traffic.noTrafficData') }}</td>
</tr>
<tr v-for="(u, i) in userTrafficToday" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ u.username }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.rx_bytes) }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.tx_bytes) }}</td>
<td class="px-6 py-3 text-right text-gray-900 dark:text-white font-medium tabular-nums">{{ formatBytes(u.total_bytes) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+4 -1
View File
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { t } = useI18n()
const username = ref('')
const password = ref('')
const error = ref('')
const successMsg = ref(route.query.msg === 'password_changed' ? t('login.passwordChanged') : '')
const loading = ref(false)
async function handleLogin() {
@@ -71,6 +73,7 @@ async function handleLogin() {
/>
</div>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
<p v-if="successMsg" class="text-sm text-green-500">{{ successMsg }}</p>
<button
type="submit"
:disabled="loading"
+105
View File
@@ -1,13 +1,67 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import TrafficChart from '@/components/TrafficChart.vue'
const authStore = useAuthStore()
const router = useRouter()
const { t } = useI18n()
interface VpnConnection {
ip: string
ip6?: string
connected_at: string
}
const vpnConnections = ref<VpnConnection[]>([])
const maxConns = ref(30)
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const myTraffic7d = ref<TrafficRecord[]>([])
const todayRx = ref(0)
const todayTx = ref(0)
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
async function fetchMyTraffic() {
try {
const res = await fetch('/api/me/traffic?days=7', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
myTraffic7d.value = data.records || []
todayRx.value = data.today_rx_bytes || 0
todayTx.value = data.today_tx_bytes || 0
} catch {}
}
async function fetchVpnConnections() {
try {
const res = await fetch('/api/me/vpn/connections', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
vpnConnections.value = data.connections || []
maxConns.value = data.max_conns_per_user || 30
} catch {}
}
onMounted(async () => {
await authStore.fetchUser()
fetchVpnConnections()
fetchMyTraffic()
})
const showPasswordModal = ref(false)
@@ -49,6 +103,8 @@ async function handleChangePassword() {
throw new Error(data.error || t('profile.passwordChangeFailed'))
}
showPasswordModal.value = false
authStore.logout()
router.push({ name: 'login', query: { msg: 'password_changed' } })
} catch (e: any) {
passwordError.value = e.message || t('profile.passwordChangeFailed')
} finally {
@@ -69,6 +125,55 @@ async function handleChangePassword() {
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.myTraffic') }}</h3>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.upload') }}</p>
<p class="text-lg font-bold text-sky-600 dark:text-sky-400 tabular-nums">{{ formatBytes(todayRx) }}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.download') }}</p>
<p class="text-lg font-bold text-green-600 dark:text-green-400 tabular-nums">{{ formatBytes(todayTx) }}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.total') }}</p>
<p class="text-lg font-bold text-gray-900 dark:text-white tabular-nums">{{ formatBytes(todayRx + todayTx) }}</p>
</div>
</div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{{ t('traffic.trafficHistory7d') }}</h4>
<TrafficChart :records="myTraffic7d" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<div class="flex items-center justify-between p-6 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.myVpnConnections') }}</h3>
<span class="px-3 py-1 text-sm font-medium rounded-full" :class="vpnConnections.length > 0 ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'">
{{ vpnConnections.length }} / {{ maxConns }}
</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!vpnConnections.length">
<td colspan="3" class="px-6 py-6 text-center text-gray-400">{{ t('profile.noConnections') }}</td>
</tr>
<tr v-for="(c, i) in vpnConnections" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
</tr>
</tbody>
</table>
<p class="text-xs text-gray-400 px-6 py-4">{{ t('profile.abnormalConnectionHint') }}</p>
</div>
<button
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
@click="openPasswordModal"
+7 -26
View File
@@ -16,8 +16,10 @@ interface Settings {
allow_client_to_client: boolean
do_local_ip_config: boolean
do_remote_ip_config: boolean
max_conns_per_user: number
}
interface ClientInfo {
user_id: number
username: string
ip: string
ip6?: string
@@ -82,6 +84,7 @@ const form = ref<Settings>({
allow_client_to_client: false,
do_local_ip_config: true,
do_remote_ip_config: true,
max_conns_per_user: 30,
})
async function fetchSettings() {
@@ -402,6 +405,10 @@ onMounted(() => {
<option :value="false">{{ t('vpn.manual') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.maxConnsPerUser') }}</label>
<input v-model.number="form.max_conns_per_user" type="number" min="1" max="1000" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</div>
<div class="flex items-center gap-4 mt-6">
<button
@@ -415,32 +422,6 @@ onMounted(() => {
</div>
</div>
<!-- 在线客户端 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!status?.clients?.length">
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
</tr>
<tr v-for="(c, i) in status?.clients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '—' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 静态预留 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="flex items-center justify-between p-6 pb-4">
+9 -109
View File
@@ -1,26 +1,14 @@
#!/usr/bin/env bash
set -e
# ─────────────────────────────────────────────────────────────
# VPN 子网(与后台 VPN 设置中的子网保持一致)
# 若后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 iptables 规则
# ─────────────────────────────────────────────────────────────
VPN_SUBNET="192.168.77.0/24"
# IPv6 子网(留空则不配置 IPv6 NAT;与后台设置保持一致)
VPN_SUBNET6="fd00:dead:beef::/112"
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh"
exit 1
fi
# 部署分支(默认 main,调试时可改为其他分支如 debug-logging
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
echo ">>> 拉取最新代码(分支: $DEPLOY_BRANCH,强制覆盖本地修改)..."
git fetch origin "$DEPLOY_BRANCH"
git checkout -B "$DEPLOY_BRANCH" FETCH_HEAD
echo ">>> 当前 commit: $(git rev-parse --short HEAD) $(git log -1 --format=%s)"
echo ">>> 拉取最新代码(强制覆盖本地修改)..."
git fetch origin
git reset --hard origin/main
echo ">>> 安装前端依赖并构建..."
cd frontend
@@ -53,106 +41,14 @@ chown -R lmvpn:lmvpn /opt/lmvpn
echo ">>> 配置内核 IP 转发..."
# 临时生效
sysctl -w net.ipv4.ip_forward=1 >/dev/null
if [ -n "$VPN_SUBNET6" ]; then
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null
fi
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null
# 持久化
cat > /etc/sysctl.d/99-lmvpn.conf << EOF
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
if [ -n "$VPN_SUBNET6" ]; then
echo "net.ipv6.conf.all.forwarding = 1" >> /etc/sysctl.d/99-lmvpn.conf
fi
sysctl -p /etc/sysctl.d/99-lmvpn.conf >/dev/null
echo ">>> 配置 NAT 与转发规则..."
# 自动检测默认路由出口网卡
WAN_IFACE=$(ip route show default 2>/dev/null | awk '{print $5; exit}')
if [ -z "$WAN_IFACE" ]; then
echo "警告: 未能自动检测出口网卡,跳过 NAT 配置,请手动设置"
else
echo "出口网卡: $WAN_IFACE"
# 选择可用的防火墙前端:优先 nft(原生,Debian 12+ 的 iptables 是 nft 包装器,操作原生 nft nat 表会不兼容)
NAT_TOOL=""
if command -v nft >/dev/null 2>&1; then
NAT_TOOL="nft"
elif command -v iptables >/dev/null 2>&1; then
NAT_TOOL="iptables"
fi
if [ -z "$NAT_TOOL" ]; then
echo "警告: 未找到 nft 或 iptables,跳过 NAT 配置"
echo " 客户端将无法上外网。请安装: apt install nftables"
echo " 安装后重新执行本脚本即可自动配置"
elif [ "$NAT_TOOL" = "nft" ]; then
echo "使用 nft 配置 NAT..."
# 幂等:先删除旧表(忽略错误),再创建
nft delete table inet lmvpn_nat 2>/dev/null || true
nft add table inet lmvpn_nat
nft 'add chain inet lmvpn_nat postrouting { type nat hook postrouting priority 100 ; }'
nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip saddr "$VPN_SUBNET" masquerade
if [ -n "$VPN_SUBNET6" ]; then
nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip6 saddr "$VPN_SUBNET6" masquerade
fi
nft 'add chain inet lmvpn_nat forward { type filter hook forward priority 0 ; policy accept ; }'
nft add rule inet lmvpn_nat forward ip saddr "$VPN_SUBNET" accept
nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept
if [ -n "$VPN_SUBNET6" ]; then
nft add rule inet lmvpn_nat forward ip6 saddr "$VPN_SUBNET6" accept
nft add rule inet lmvpn_nat forward ip6 daddr "$VPN_SUBNET6" accept
fi
# 持久化 nft 规则
mkdir -p /etc/nftables.d
nft list ruleset > /etc/nftables.d/lmvpn.nft
# 确保主配置文件 include 该目录
if [ -f /etc/nftables.conf ] && ! grep -q 'include "/etc/nftables.d' /etc/nftables.conf; then
echo 'include "/etc/nftables.d/*.nft"' >> /etc/nftables.conf
fi
systemctl enable nftables 2>/dev/null || true
echo "nft 规则已写入 /etc/nftables.d/lmvpn.nft"
elif [ "$NAT_TOOL" = "iptables" ]; then
echo "使用 iptables 配置 NAT..."
# 幂等:先删除旧规则(忽略错误),再添加
iptables -t nat -D POSTROUTING -s "$VPN_SUBNET" -o "$WAN_IFACE" -j MASQUERADE 2>/dev/null || true
iptables -t nat -A POSTROUTING -s "$VPN_SUBNET" -o "$WAN_IFACE" -j MASQUERADE
iptables -D FORWARD -s "$VPN_SUBNET" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -d "$VPN_SUBNET" -j ACCEPT 2>/dev/null || true
iptables -A FORWARD -s "$VPN_SUBNET" -j ACCEPT
iptables -A FORWARD -d "$VPN_SUBNET" -j ACCEPT
# IPv6 NAT(需 ip6tables
if [ -n "$VPN_SUBNET6" ] && command -v ip6tables >/dev/null 2>&1; then
echo "配置 IPv6 NAT (ip6tables)..."
ip6tables -t nat -D POSTROUTING -s "$VPN_SUBNET6" -o "$WAN_IFACE" -j MASQUERADE 2>/dev/null || true
ip6tables -t nat -A POSTROUTING -s "$VPN_SUBNET6" -o "$WAN_IFACE" -j MASQUERADE
ip6tables -D FORWARD -s "$VPN_SUBNET6" -j ACCEPT 2>/dev/null || true
ip6tables -D FORWARD -d "$VPN_SUBNET6" -j ACCEPT 2>/dev/null || true
ip6tables -A FORWARD -s "$VPN_SUBNET6" -j ACCEPT
ip6tables -A FORWARD -d "$VPN_SUBNET6" -j ACCEPT
fi
# 持久化 iptables 规则
if command -v netfilter-persistent >/dev/null 2>&1; then
netfilter-persistent save
echo "iptables 规则已通过 netfilter-persistent 持久化"
elif command -v iptables-save >/dev/null 2>&1; then
mkdir -p /etc/iptables
iptables-save > /etc/iptables/rules.v4
if [ -n "$VPN_SUBNET6" ] && command -v ip6tables-save >/dev/null 2>&1; then
ip6tables-save > /etc/iptables/rules.v6
echo "ip6tables 规则已写入 /etc/iptables/rules.v6"
fi
echo "iptables 规则已写入 /etc/iptables/rules.v4"
echo "注意: 重启后规则持久化需配合 iptables-persistent 包,建议安装: apt install iptables-persistent"
else
echo "警告: 未找到 iptables-save,规则仅在本次运行有效,请手动持久化"
fi
fi
fi
echo ">>> 安装 systemd 服务..."
cat > /etc/systemd/system/lmvpn.service << 'EOF'
[Unit]
@@ -180,4 +76,8 @@ systemctl enable lmvpn
systemctl restart lmvpn
echo ">>> 安装完成"
echo ""
echo "NAT/转发/UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置。"
echo "若修改了后台 VPN 子网,只需在后台保存即可,程序会自动更新防火墙规则。"
echo ""
systemctl status lmvpn --no-pager
+1 -1
View File
@@ -33,7 +33,7 @@ func defaultConfig() *Config {
return &Config{
Web: WebConfig{
Port: 8080,
Sock: "/run/lmvpnweb.sock",
Sock: "web.sock",
SockMode: "0666",
SockDirMode: "0755",
},
+18 -9
View File
@@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error {
return fmt.Errorf("数据库连接失败: %w", err)
}
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}); err != nil {
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}, &model.UserTrafficStat{}); err != nil {
return fmt.Errorf("数据库迁移失败: %w", err)
}
@@ -105,21 +105,30 @@ func seedDefaultAdmin(cfg *config.DatabaseConfig) error {
func seedDefaultVpnSettings() error {
var s model.VpnSetting
if err := DB.First(&s, model.VpnSettingSingletonID).Error; err == nil {
needSave := false
if s.Subnet6 == "" {
s.Subnet6 = "fd00:dead:beef::/112"
needSave = true
}
if s.MaxConnsPerUser == 0 {
s.MaxConnsPerUser = 30
needSave = true
}
if needSave {
DB.Save(&s)
}
return nil
}
s = model.VpnSetting{
ID: model.VpnSettingSingletonID,
Enabled: false,
Subnet: "192.168.77.0/24",
Subnet6: "fd00:dead:beef::/112",
MTU: 1420,
InterfaceName: "",
DoLocalIPConfig: true,
DoRemoteIPConfig: true,
ID: model.VpnSettingSingletonID,
Enabled: false,
Subnet: "192.168.77.0/24",
Subnet6: "fd00:dead:beef::/112",
MTU: 1420,
InterfaceName: "",
DoLocalIPConfig: true,
DoRemoteIPConfig: true,
MaxConnsPerUser: 30,
}
return DB.Create(&s).Error
}
+25 -4
View File
@@ -1,18 +1,36 @@
package handler
import (
"errors"
"net/http"
"time"
"lmvpn/internal/db"
"lmvpn/internal/middleware"
"lmvpn/internal/model"
"lmvpn/internal/vpn"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
minPasswordLen = 6
maxPasswordLen = 72 // bcrypt 硬上限:超过 72 字节会被 bcrypt 截断或报错
)
func validatePassword(pw string) error {
n := len(pw)
if n < minPasswordLen {
return errors.New("密码长度不能少于6位")
}
if n > maxPasswordLen {
return errors.New("密码长度不能超过72字节")
}
return nil
}
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
@@ -93,8 +111,8 @@ func ChangePassword(c *gin.Context) {
return
}
if len(req.NewPassword) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码长度不能少于6位"})
if err := validatePassword(req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -126,8 +144,11 @@ func ChangePassword(c *gin.Context) {
return
}
sessionID, _ := c.Get("session_id")
db.DB.Model(&model.Session{}).Where("user_id = ? AND session_id != ?", userID, sessionID).Update("invalid", true)
db.DB.Model(&model.Session{}).Where("user_id = ?", userID).Update("invalid", true)
if vpn.VPN != nil && vpn.VPN.Running() {
vpn.VPN.KickUser(user.ID)
}
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
+255
View File
@@ -0,0 +1,255 @@
package handler
import (
"net/http"
"strconv"
"time"
"lmvpn/internal/db"
"lmvpn/internal/model"
"lmvpn/internal/vpn"
"github.com/gin-gonic/gin"
)
type userTrafficItem struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
TotalBytes int64 `json:"total_bytes"`
}
type trafficRecord struct {
Date string `json:"date"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func parseDays(c *gin.Context) int {
days := 7
if d := c.Query("days"); d != "" {
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
days = n
}
}
return days
}
func GetAdminTrafficToday(c *gin.Context) {
today := time.Now().Format("2006-01-02")
var stats []model.UserTrafficStat
db.DB.Where("date = ?", today).Find(&stats)
userIDs := make([]uint, 0, len(stats))
for _, s := range stats {
userIDs = append(userIDs, s.UserID)
}
nameMap := make(map[uint]string)
if len(userIDs) > 0 {
var users []model.User
db.DB.Where("id IN ?", userIDs).Find(&users)
for _, u := range users {
nameMap[u.ID] = u.Username
}
}
items := make([]userTrafficItem, 0, len(stats))
var totalRx, totalTx int64
seen := make(map[uint]bool)
for _, s := range stats {
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(s.UserID)
}
rx := s.RxBytes + liveRx
tx := s.TxBytes + liveTx
items = append(items, userTrafficItem{
UserID: s.UserID,
Username: nameMap[s.UserID],
RxBytes: rx,
TxBytes: tx,
TotalBytes: rx + tx,
})
totalRx += rx
totalTx += tx
seen[s.UserID] = true
}
if vpn.VPN != nil && vpn.VPN.Running() {
for _, ci := range vpn.VPN.ClientList() {
if seen[ci.UserID] {
continue
}
liveRx, liveTx := vpn.VPN.UserLiveTraffic(ci.UserID)
if liveRx == 0 && liveTx == 0 {
continue
}
var u model.User
if err := db.DB.First(&u, ci.UserID).Error; err != nil {
continue
}
items = append(items, userTrafficItem{
UserID: ci.UserID,
Username: u.Username,
RxBytes: liveRx,
TxBytes: liveTx,
TotalBytes: liveRx + liveTx,
})
totalRx += liveRx
totalTx += liveTx
seen[ci.UserID] = true
}
}
c.JSON(http.StatusOK, gin.H{
"total_rx_bytes": totalRx,
"total_tx_bytes": totalTx,
"users": items,
})
}
func GetAdminTrafficHistory(c *gin.Context) {
days := parseDays(c)
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
var stats []model.TrafficStat
db.DB.Where("date >= ?", startDate).Order("date asc").Find(&stats)
dateMap := make(map[string]trafficRecord, len(stats))
for _, s := range stats {
dateMap[s.Date] = trafficRecord{
Date: s.Date,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
}
}
out := make([]trafficRecord, 0, days)
for i := days - 1; i >= 0; i-- {
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
if r, ok := dateMap[d]; ok {
out = append(out, r)
} else {
out = append(out, trafficRecord{Date: d})
}
}
today := time.Now().Format("2006-01-02")
var todayStat model.TrafficStat
db.DB.Where("date = ?", today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.TotalLiveTraffic()
}
c.JSON(http.StatusOK, gin.H{
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"records": out,
})
}
func GetAdminUserTraffic(c *gin.Context) { idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
var user model.User
if err := db.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
days := parseDays(c)
records := queryUserTraffic(uint(id), days)
today := time.Now().Format("2006-01-02")
var todayStat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", id, today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uint(id))
}
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"username": user.Username,
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"today_live_rx": liveRx,
"today_live_tx": liveTx,
"records": records,
})
}
func GetMyTrafficToday(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
today := time.Now().Format("2006-01-02")
var stat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&stat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
}
c.JSON(http.StatusOK, gin.H{
"rx_bytes": stat.RxBytes + liveRx,
"tx_bytes": stat.TxBytes + liveTx,
})
}
func GetMyTrafficHistory(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
days := parseDays(c)
records := queryUserTraffic(uid, days)
today := time.Now().Format("2006-01-02")
var todayStat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
}
c.JSON(http.StatusOK, gin.H{
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"today_live_rx": liveRx,
"today_live_tx": liveTx,
"records": records,
})
}
func queryUserTraffic(userID uint, days int) []trafficRecord {
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
var stats []model.UserTrafficStat
db.DB.Where("user_id = ? AND date >= ?", userID, startDate).Order("date asc").Find(&stats)
dateMap := make(map[string]trafficRecord, len(stats))
for _, s := range stats {
dateMap[s.Date] = trafficRecord{
Date: s.Date,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
}
}
out := make([]trafficRecord, 0, days)
for i := days - 1; i >= 0; i-- {
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
if r, ok := dateMap[d]; ok {
out = append(out, r)
} else {
out = append(out, trafficRecord{Date: d})
}
}
return out
}
+17
View File
@@ -7,6 +7,7 @@ import (
"lmvpn/internal/db"
"lmvpn/internal/model"
"lmvpn/internal/vpn"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -82,6 +83,11 @@ func CreateUser(c *gin.Context) {
return
}
if err := validatePassword(req.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
@@ -138,6 +144,10 @@ func UpdateUser(c *gin.Context) {
updates := map[string]interface{}{}
if req.Status != nil {
if user.ID == currentUserID.(uint) && *req.Status != 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
return
}
updates["status"] = *req.Status
}
@@ -162,6 +172,10 @@ func UpdateUser(c *gin.Context) {
}
if req.Password != "" {
if err := validatePassword(req.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
@@ -183,6 +197,9 @@ func UpdateUser(c *gin.Context) {
if req.Password != "" || req.Role != "" || req.Status != nil {
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
if vpn.VPN != nil && vpn.VPN.Running() {
vpn.VPN.KickUser(uint(id))
}
}
db.DB.First(&user, id)
+76
View File
@@ -22,6 +22,7 @@ type vpnSettingsResponse struct {
AllowClientToClient bool `json:"allow_client_to_client"`
DoLocalIPConfig bool `json:"do_local_ip_config"`
DoRemoteIPConfig bool `json:"do_remote_ip_config"`
MaxConnsPerUser int `json:"max_conns_per_user"`
}
type updateVpnSettingsRequest struct {
@@ -33,6 +34,7 @@ type updateVpnSettingsRequest struct {
AllowClientToClient *bool `json:"allow_client_to_client"`
DoLocalIPConfig *bool `json:"do_local_ip_config"`
DoRemoteIPConfig *bool `json:"do_remote_ip_config"`
MaxConnsPerUser *int `json:"max_conns_per_user"`
}
func loadVpnSettings() (model.VpnSetting, error) {
@@ -131,6 +133,7 @@ func GetVpnSettings(c *gin.Context) {
AllowClientToClient: s.AllowClientToClient,
DoLocalIPConfig: s.DoLocalIPConfig,
DoRemoteIPConfig: s.DoRemoteIPConfig,
MaxConnsPerUser: s.MaxConnsPerUser,
})
}
@@ -185,6 +188,13 @@ func UpdateVpnSettings(c *gin.Context) {
if req.DoRemoteIPConfig != nil {
s.DoRemoteIPConfig = *req.DoRemoteIPConfig
}
if req.MaxConnsPerUser != nil {
if *req.MaxConnsPerUser < 1 || *req.MaxConnsPerUser > 1000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "每用户最大连接数范围 1-1000"})
return
}
s.MaxConnsPerUser = *req.MaxConnsPerUser
}
if err := db.DB.Save(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存设置失败"})
@@ -198,6 +208,42 @@ func UpdateVpnSettings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "设置已更新"})
}
type myVpnConnection struct {
IP string `json:"ip"`
IP6 string `json:"ip6,omitempty"`
ConnectedAt string `json:"connected_at"`
}
func GetMyVpnConnections(c *gin.Context) {
userID, _ := c.Get("user_id")
maxConns := 30
if vpn.VPN != nil {
s := vpn.VPN.Settings()
if s.MaxConnsPerUser > 0 {
maxConns = s.MaxConnsPerUser
}
}
connections := make([]myVpnConnection, 0)
if vpn.VPN != nil && vpn.VPN.Running() {
for _, ci := range vpn.VPN.ClientList() {
if ci.UserID == userID.(uint) {
connections = append(connections, myVpnConnection{
IP: ci.IP,
IP6: ci.IP6,
ConnectedAt: ci.ConnectedAt,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"max_conns_per_user": maxConns,
"connections": connections,
})
}
type vpnStatusResponse struct {
Enabled bool `json:"enabled"`
Online int `json:"online"`
@@ -406,3 +452,33 @@ func DeleteVpnReservation(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
func KickUserClient(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
var user model.User
if err := db.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
currentUserID, _ := c.Get("user_id")
if uint(id) == currentUserID.(uint) {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能踢自己下线,如需断开自己的设备请修改密码"})
return
}
n := 0
db.DB.Model(&user).Update("status", 0)
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
if vpn.VPN != nil && vpn.VPN.Running() {
n = vpn.VPN.KickUser(uint(id))
}
c.JSON(http.StatusOK, gin.H{"message": "已断开用户连接并禁用账号", "kicked": n})
}
+9
View File
@@ -84,3 +84,12 @@ func LoginRateLimit() gin.HandlerFunc {
c.Next()
}
}
// BodyLimit 限制请求体大小,防止内存耗尽型 DoS。
// 对所有 /api JSON 端点生效;WebSocket 走连接劫持,握手阶段不受影响。
func BodyLimit(max int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max)
c.Next()
}
}
+14
View File
@@ -14,6 +14,7 @@ type VpnSetting struct {
AllowClientToClient bool `gorm:"default:false"`
DoLocalIPConfig bool `gorm:"default:true"`
DoRemoteIPConfig bool `gorm:"default:true"`
MaxConnsPerUser int `gorm:"default:30"`
UpdatedAt time.Time
}
@@ -44,3 +45,16 @@ type TrafficStat struct {
func (TrafficStat) TableName() string {
return "traffic_stats"
}
type UserTrafficStat struct {
ID uint `gorm:"primaryKey;autoIncrement"`
UserID uint `gorm:"uniqueIndex:idx_user_date;not null"`
Date string `gorm:"uniqueIndex:idx_user_date;size:10;not null"`
RxBytes int64 `gorm:"default:0"`
TxBytes int64 `gorm:"default:0"`
UpdatedAt time.Time
}
func (UserTrafficStat) TableName() string {
return "user_traffic_stats"
}
+13 -3
View File
@@ -14,20 +14,25 @@ import (
func Setup(r *gin.Engine) {
r.GET("/ws", vpn.HandleWS)
r.POST("/api/login", middleware.LoginRateLimit(), handler.Login)
bodyLimit := middleware.BodyLimit(1 << 20) // 1 MiB
r.POST("/api/login", bodyLimit, middleware.LoginRateLimit(), handler.Login)
r.GET("/api/version", handler.GetVersion)
auth := r.Group("/api")
auth.Use(middleware.AuthMiddleware())
auth.Use(middleware.AuthMiddleware(), bodyLimit)
{
auth.GET("/me", handler.Me)
auth.PUT("/me/password", handler.ChangePassword)
auth.GET("/me/sessions", handler.ListMySessions)
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
auth.GET("/me/vpn/connections", handler.GetMyVpnConnections)
auth.GET("/me/traffic/today", handler.GetMyTrafficToday)
auth.GET("/me/traffic", handler.GetMyTrafficHistory)
}
admin := r.Group("/api/admin")
admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware())
admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware(), bodyLimit)
{
admin.GET("/stats", handler.GetAdminStats)
admin.GET("/users/count", handler.GetUserCount)
@@ -44,6 +49,11 @@ func Setup(r *gin.Engine) {
admin.GET("/vpn/reservations", handler.ListVpnReservations)
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
admin.DELETE("/vpn/clients/:id", handler.KickUserClient)
admin.GET("/traffic/today", handler.GetAdminTrafficToday)
admin.GET("/traffic/history", handler.GetAdminTrafficHistory)
admin.GET("/traffic/users/:id", handler.GetAdminUserTraffic)
}
distDir := http.Dir("./dist")
+2
View File
@@ -18,6 +18,8 @@ type DiagResult struct {
IP6ForwardNote string `json:"ip6_forward_note,omitempty"`
Masquerade6 *bool `json:"masquerade6"`
Masquerade6Note string `json:"masquerade6_note,omitempty"`
UFWActive *bool `json:"ufw_active"`
UFWForwardNote string `json:"ufw_forward_note,omitempty"`
TUNCreate string `json:"tun_create"`
TUNRunning bool `json:"tun_running"`
TUNName string `json:"tun_name,omitempty"`
+37
View File
@@ -34,6 +34,15 @@ func fillPlatformDiag(r *DiagResult) {
m6, note6 := checkMasquerade6()
r.Masquerade6 = m6
r.Masquerade6Note = note6
ufwActive := checkUFWActive()
r.UFWActive = &ufwActive
if ufwActive {
ufwOk, ufwNote := checkUFWForward()
if !ufwOk {
r.UFWForwardNote = ufwNote
}
}
}
func ptrBool(b bool) *bool { return &b }
@@ -163,3 +172,31 @@ func checkMasquerade6() (*bool, string) {
return nil, "ip6tables 与 nft 均未安装,无法检测 IPv6 NAT 规则"
}
// checkUFWForward checks if VPN forward rules exist in UFW's user-forward chains.
func checkUFWForward() (bool, string) {
nftPath := findExecutable("nft")
if nftPath == "" {
return true, ""
}
// Check IPv4
out, err := exec.Command(nftPath, "list", "chain", "ip", "filter", "ufw-user-forward").Output()
if err == nil {
s := string(out)
if !strings.Contains(s, "jump lmvpn-fwd") && !strings.Contains(s, "192.168.") {
return false, "UFW 已启用但 IPv4 转发规则未配置,客户端可能无法上网"
}
}
// Check IPv6
out6, err := exec.Command(nftPath, "list", "chain", "ip6", "filter", "ufw6-user-forward").Output()
if err == nil {
s := string(out6)
if !strings.Contains(s, "jump lmvpn6-fwd") && !strings.Contains(s, "fd00:") {
return false, "UFW 已启用但 IPv6 转发规则未配置,IPv6 客户端可能无法上网"
}
}
return true, ""
}
+13
View File
@@ -0,0 +1,13 @@
//go:build darwin
package vpn
import "log"
func configureFirewall(ipNet, ipNet6 string, tunName string) {
log.Printf("防火墙配置在当前平台不支持,请手动配置 NAT 和转发规则")
}
func checkUFWActive() bool {
return false
}
+152
View File
@@ -0,0 +1,152 @@
//go:build linux
package vpn
import (
"fmt"
"log"
"os/exec"
"strings"
)
// configureFirewall dynamically configures NAT masquerade, forward accept rules,
// and UFW forward rules based on the current VPN subnets.
// This is called from ApplySettings() so subnet changes in the backend
// are automatically reflected in firewall rules.
func configureFirewall(ipNet, ipNet6 string, tunName string) {
wanIface := detectWANInterface()
if wanIface == "" {
log.Printf("警告: 未能检测出口网卡,跳过防火墙配置")
return
}
log.Printf("出口网卡: %s", wanIface)
configureNAT(wanIface, ipNet, ipNet6)
configureForward(ipNet, ipNet6)
configureUFWForward(ipNet, ipNet6)
}
func detectWANInterface() string {
out, err := exec.Command("ip", "route", "show", "default").Output()
if err != nil {
return ""
}
fields := strings.Fields(string(out))
for i, f := range fields {
if f == "dev" && i+1 < len(fields) {
return fields[i+1]
}
}
return ""
}
func nftExists(args ...string) bool {
return exec.Command("nft", args...).Run() == nil
}
func nftExec(args ...string) {
cmd := exec.Command("nft", args...)
cmd.Stderr = nil
_ = cmd.Run()
}
func configureNAT(wanIface, ipNet, ipNet6 string) {
if !nftExists("list", "table", "inet", "lmvpn_nat") {
nftExec("add", "table", "inet", "lmvpn_nat")
}
// postrouting chain
if !nftExists("list", "chain", "inet", "lmvpn_nat", "postrouting") {
nftExec("add", "chain", "inet", "lmvpn_nat", "postrouting",
"{ type nat hook postrouting priority 100 ; }")
}
nftExec("flush", "chain", "inet", "lmvpn_nat", "postrouting")
if ipNet != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "postrouting",
"oifname", wanIface, "ip", "saddr", ipNet, "masquerade")
}
if ipNet6 != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "postrouting",
"oifname", wanIface, "ip6", "saddr", ipNet6, "masquerade")
}
log.Printf("NAT masquerade 已配置: wan=%s v4=%s", wanIface, ipNet)
if ipNet6 != "" {
log.Printf("NAT masquerade IPv6: %s", ipNet6)
}
}
func configureForward(ipNet, ipNet6 string) {
if !nftExists("list", "chain", "inet", "lmvpn_nat", "forward") {
nftExec("add", "chain", "inet", "lmvpn_nat", "forward",
"{ type filter hook forward priority 0 ; policy accept ; }")
}
nftExec("flush", "chain", "inet", "lmvpn_nat", "forward")
if ipNet != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip", "saddr", ipNet, "accept")
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip", "daddr", ipNet, "accept")
}
if ipNet6 != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip6", "saddr", ipNet6, "accept")
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip6", "daddr", ipNet6, "accept")
}
}
// configureUFWForward adds VPN subnet accept rules to UFW's user-forward chains.
// UFW's FORWARD chain has policy DROP by default, which overrides the lmvpn_nat
// forward chain's accept rules. We use dedicated chains (lmvpn-fwd/lmvpn6-fwd)
// that are jumped to from ufw-user-forward/ufw6-user-forward.
func configureUFWForward(ipNet, ipNet6 string) {
// IPv4
if nftExists("list", "chain", "ip", "filter", "ufw-user-forward") {
if !nftExists("list", "chain", "ip", "filter", "lmvpn-fwd") {
nftExec("add", "chain", "ip", "filter", "lmvpn-fwd")
}
nftExec("flush", "chain", "ip", "filter", "lmvpn-fwd")
if ipNet != "" {
nftExec("add", "rule", "ip", "filter", "lmvpn-fwd",
"ip", "saddr", ipNet, "accept")
nftExec("add", "rule", "ip", "filter", "lmvpn-fwd",
"ip", "daddr", ipNet, "accept")
}
// Add jump if not already present
if !nftJumpExists("ip", "filter", "ufw-user-forward", "lmvpn-fwd") {
nftExec("add", "rule", "ip", "filter", "ufw-user-forward", "jump", "lmvpn-fwd")
}
log.Printf("UFW IPv4 转发规则已配置")
}
// IPv6
if ipNet6 != "" && nftExists("list", "chain", "ip6", "filter", "ufw6-user-forward") {
if !nftExists("list", "chain", "ip6", "filter", "lmvpn6-fwd") {
nftExec("add", "chain", "ip6", "filter", "lmvpn6-fwd")
}
nftExec("flush", "chain", "ip6", "filter", "lmvpn6-fwd")
nftExec("add", "rule", "ip6", "filter", "lmvpn6-fwd",
"ip6", "saddr", ipNet6, "accept")
nftExec("add", "rule", "ip6", "filter", "lmvpn6-fwd",
"ip6", "daddr", ipNet6, "accept")
if !nftJumpExists("ip6", "filter", "ufw6-user-forward", "lmvpn6-fwd") {
nftExec("add", "rule", "ip6", "filter", "ufw6-user-forward", "jump", "lmvpn6-fwd")
}
log.Printf("UFW IPv6 转发规则已配置")
}
}
// nftJumpExists checks if a jump rule to the target chain already exists
// in the source chain.
func nftJumpExists(family, table, chain, target string) bool {
out, err := exec.Command("nft", "list", "chain", family, table, chain).Output()
if err != nil {
return false
}
return strings.Contains(string(out), fmt.Sprintf("jump %s", target))
}
// checkUFWActive checks if UFW is active by looking for its forward chain.
func checkUFWActive() bool {
return nftExists("list", "chain", "ip", "filter", "ufw-user-forward")
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux && !darwin
package vpn
import "log"
func configureFirewall(ipNet, ipNet6 string, tunName string) {
log.Printf("防火墙配置在当前平台不支持,请手动配置 NAT 和转发规则")
}
func checkUFWActive() bool {
return false
}
+3 -6
View File
@@ -31,16 +31,14 @@ var upgrader = websocket.Upgrader{
func HandleWS(c *gin.Context) {
tokenStr := c.Query("token")
clientIP := c.ClientIP()
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[WS] upgrade failed clientIP=%s err=%v", clientIP, err)
log.Printf("WebSocket 升级失败: %v", err)
return
}
if tokenStr != "" {
log.Printf("[WS] upgrade clientIP=%s auth=jwt", clientIP)
claims, err := middleware.ParseToken(tokenStr)
if err != nil {
sendJSON(conn, authResponse{Type: "auth_err", Message: "令牌无效或已过期"})
@@ -57,10 +55,9 @@ func HandleWS(c *gin.Context) {
return
}
log.Printf("[WS] upgrade clientIP=%s auth=password", clientIP)
user, err := authenticate(conn, db.DB, clientIP)
user, err := authenticate(conn, db.DB, c.ClientIP())
if err != nil {
log.Printf("[WS] auth read failed clientIP=%s err=%v", clientIP, err)
log.Printf("认证读取失败: %v", err)
conn.Close()
return
}
+91 -49
View File
@@ -27,6 +27,7 @@ type VpnService struct {
switchx *PacketSwitch
tun *TUNInterface
tunDone chan struct{}
flushDone chan struct{}
running bool
startedAt time.Time
clients map[*tunnelConn]struct{}
@@ -50,16 +51,59 @@ func (s *VpnService) StartedAt() time.Time {
return s.startedAt
}
const trafficFlushPeriod = 60 * time.Second
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
s.mu.RLock()
for c := range s.clients {
rx += c.rxBytes.Load()
tx += c.txBytes.Load()
rx += c.rxBytes.Load() - c.flushedRx.Load()
tx += c.txBytes.Load() - c.flushedTx.Load()
}
s.mu.RUnlock()
return
}
func (s *VpnService) UserLiveTraffic(userID uint) (rx, tx int64) {
s.mu.RLock()
for c := range s.clients {
if c.user.ID == userID {
rx += c.rxBytes.Load() - c.flushedRx.Load()
tx += c.txBytes.Load() - c.flushedTx.Load()
}
}
s.mu.RUnlock()
return
}
func (s *VpnService) trafficFlusher() {
ticker := time.NewTicker(trafficFlushPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.flushAllTraffic()
case <-s.flushDone:
return
}
}
}
func (s *VpnService) flushAllTraffic() {
s.mu.RLock()
conns := make([]*tunnelConn, 0, len(s.clients))
for c := range s.clients {
conns = append(conns, c)
}
s.mu.RUnlock()
for _, c := range conns {
rx, tx := c.flushDelta()
if rx > 0 || tx > 0 {
recordTraffic(c.user.ID, rx, tx)
}
}
}
func (s *VpnService) Settings() model.VpnSetting {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -150,18 +194,25 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
s.switchx = NewPacketSwitch(settings.AllowClientToClient)
s.tun = tun
s.tunDone = make(chan struct{})
s.flushDone = make(chan struct{})
s.running = true
s.startedAt = time.Now()
s.mu.Unlock()
go s.serveTUN()
go s.startStatsLogger()
log.Printf("[TUN] created name=%s mtu=%d", tun.Name(), settings.MTU)
log.Printf("[TUN] ipv4 addr=%s/%d subnet=%s server=%s", serverIP.String(), prefix, ipNet.String(), serverIP.String())
go s.trafficFlusher()
subnet4 := ipNet.String()
var subnet6Str string
if ipNet6 != nil {
log.Printf("[TUN] ipv6 addr=%s/%d subnet=%s server=%s", serverIP6.String(), prefix6, ipNet6.String(), serverIP6.String())
subnet6Str = ipNet6.String()
}
configureFirewall(subnet4, subnet6Str, tun.Name())
log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.String())
if ipNet6 != nil {
log.Printf(" IPv6: subnet=%s server=%s", ipNet6.String(), serverIP6.String())
}
log.Printf("[VPN] started c2c=%v bufSize=%d", settings.AllowClientToClient, settings.MTU+64)
return nil
}
@@ -177,49 +228,17 @@ func (s *VpnService) serveTUN() {
for {
n, err := tun.Iface.Read(packet)
if err != nil {
log.Printf("[TUN-RX] read ended: %v", err)
log.Printf("TUN 读取结束: %v", err)
close(done)
return
}
if n < 1 {
continue
}
pkt := packet[:n]
srcIP, destIP, ok := parseIPAddrs(pkt)
if ok {
log.Printf("[TUN-RX] size=%d proto=%s src=%s dst=%s", n, protoName(pkt), srcIP, destIP)
} else {
log.Printf("[TUN-RX] size=%d parse=fail", n)
}
iterStart := time.Now()
targets := switchx.RouteFromTUN(pkt)
targets := switchx.RouteFromTUN(packet[:n])
for _, t := range targets {
_ = t.WritePacket(pkt)
_ = t.WritePacket(packet[:n])
}
if iterDur := time.Since(iterStart); iterDur > 10*time.Millisecond {
log.Printf("[TUN-RX] slow write iterMs=%d targets=%d size=%d", iterDur.Milliseconds(), len(targets), n)
}
}
}
func (s *VpnService) startStatsLogger() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var lastRx, lastTx int64
for range ticker.C {
if !s.Running() {
return
}
rx, tx := s.TotalLiveTraffic()
rxRate := rx - lastRx
txRate := tx - lastTx
s.mu.RLock()
n := len(s.clients)
s.mu.RUnlock()
log.Printf("[STATS] clients=%d totalRx=%d totalTx=%d intervalRx=%d(%dB/s) intervalTx=%d(%dB/s)",
n, rx, tx, rxRate, rxRate/10, txRate, txRate/10)
lastRx = rx
lastTx = tx
}
}
@@ -232,10 +251,21 @@ func (s *VpnService) Stop() error {
s.running = false
tun := s.tun
done := s.tunDone
flushDone := s.flushDone
s.flushDone = nil
clients := s.clients
s.clients = make(map[*tunnelConn]struct{})
s.mu.Unlock()
if flushDone != nil {
close(flushDone)
}
for c := range clients {
rx, tx := c.flushDelta()
if rx > 0 || tx > 0 {
recordTraffic(c.user.ID, rx, tx)
}
}
for c := range clients {
c.close()
}
@@ -245,7 +275,7 @@ func (s *VpnService) Stop() error {
<-done
}
}
log.Printf("[VPN] stopped")
log.Printf("VPN 服务已停止")
return nil
}
@@ -277,15 +307,9 @@ func (s *VpnService) WriteToTUN(packet []byte) error {
tun := s.tun
s.mu.RUnlock()
if tun == nil {
log.Printf("[TUN-TX] size=%d ok=false err=TUN not ready", len(packet))
return errors.New("TUN 未就绪")
}
_, err := tun.Iface.Write(packet)
if err != nil {
log.Printf("[TUN-TX] size=%d ok=false err=%v", len(packet), err)
} else {
log.Printf("[TUN-TX] size=%d ok=true", len(packet))
}
return err
}
@@ -407,10 +431,28 @@ func (s *VpnService) ClientList() []ClientInfo {
}
type ClientInfo struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
IP string `json:"ip"`
IP6 string `json:"ip6,omitempty"`
ConnectedAt string `json:"connected_at"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func (s *VpnService) KickUser(userID uint) int {
s.mu.RLock()
var toClose []*tunnelConn
for c := range s.clients {
if c.user.ID == userID {
toClose = append(toClose, c)
}
}
s.mu.RUnlock()
for _, c := range toClose {
c.close()
}
return len(toClose)
}
var VPN *VpnService
+17 -39
View File
@@ -1,7 +1,6 @@
package vpn
import (
"log"
"net"
"sync"
@@ -107,14 +106,14 @@ func parseIPAddrs(packet []byte) (src, dest net.IP, ok bool) {
return nil, nil, false
}
func protoName(packet []byte) string {
if waterutil.IsIPv4(packet) {
return "IPv4"
}
if waterutil.IsIPv6(packet) {
return "IPv6"
}
return "unknown"
// dropPacket is a sentinel non-nil, non-empty slice that signals the caller
// to silently drop the packet (do not write to TUN, do not relay).
var dropPacket = []SwitchConn{nil}
// isDrop returns true if the result from RouteFromClient indicates the packet
// should be silently dropped rather than forwarded to TUN or relayed.
func isDrop(targets []SwitchConn) bool {
return len(targets) == 1 && targets[0] == nil
}
func (s *PacketSwitch) allowC2C() bool {
@@ -124,70 +123,49 @@ func (s *PacketSwitch) allowC2C() bool {
return v
}
// RouteFromClient returns the list of target connections to relay the packet to.
// Returns nil to indicate the packet should be written to TUN (internet-bound).
// Returns dropPacket to indicate the packet should be silently dropped (e.g. anti-spoof).
func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchConn {
srcIP, dest, ok := parseIPAddrs(packet)
if !ok {
log.Printf("[ROUTE] client->? parse failed size=%d", len(packet))
return nil
return dropPacket
}
proto := protoName(packet)
// anti-spoof: enforce assigned source IP by version
if srcIP != nil {
if srcIP.To4() != nil {
if !srcIP.Equal(src.AssignedIP()) {
log.Printf("[ROUTE] anti-spoof drop src=%s expected=%s dst=%s proto=%s size=%d",
srcIP, src.AssignedIP(), dest, proto, len(packet))
return nil
return dropPacket
}
} else {
assigned6 := src.AssignedIP6()
if assigned6 == nil || !srcIP.Equal(assigned6) {
log.Printf("[ROUTE] anti-spoof drop src=%s expected=%s dst=%s proto=%s size=%d",
srcIP, assigned6, dest, proto, len(packet))
return nil
return dropPacket
}
}
}
if dest.IsGlobalUnicast() {
if c := s.findByIP(dest); c != nil && s.allowC2C() {
log.Printf("[ROUTE] client->relay src=%s dst=%s proto=%s size=%d",
src.AssignedIP(), dest, proto, len(packet))
return []SwitchConn{c}
}
log.Printf("[ROUTE] client->tun src=%s dst=%s proto=%s size=%d",
src.AssignedIP(), dest, proto, len(packet))
return nil
}
if s.allowC2C() {
targets := s.allExcept(src)
log.Printf("[ROUTE] client->broadcast src=%s dst=%s proto=%s size=%d targets=%d",
src.AssignedIP(), dest, proto, len(packet), len(targets))
return targets
return s.allExcept(src)
}
log.Printf("[ROUTE] client->drop(broadcast,c2c off) src=%s dst=%s proto=%s size=%d",
src.AssignedIP(), dest, proto, len(packet))
return nil
return dropPacket
}
func (s *PacketSwitch) RouteFromTUN(packet []byte) []SwitchConn {
_, dest, ok := parseIPAddrs(packet)
if !ok {
log.Printf("[ROUTE] TUN->? parse failed size=%d", len(packet))
return nil
}
proto := protoName(packet)
if dest.IsGlobalUnicast() {
if c := s.findByIP(dest); c != nil {
log.Printf("[ROUTE] TUN->client dst=%s proto=%s size=%d found=true",
dest, proto, len(packet))
return []SwitchConn{c}
}
log.Printf("[ROUTE] TUN->client dst=%s proto=%s size=%d found=false",
dest, proto, len(packet))
return nil
}
targets := s.allExcept(nil)
log.Printf("[ROUTE] TUN->broadcast dst=%s proto=%s size=%d targets=%d",
dest, proto, len(packet), len(targets))
return targets
return s.allExcept(nil)
}
+1 -8
View File
@@ -4,7 +4,6 @@ package vpn
import (
"fmt"
"log"
"net"
)
@@ -33,11 +32,5 @@ func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error {
}
func (t *TUNInterface) SetMTU(mtu int) error {
err := execCmd("ifconfig", t.Name(), "mtu", fmt.Sprintf("%d", mtu))
if err != nil {
log.Printf("[TUN] set MTU dev=%s mtu=%d ok=false err=%v", t.Name(), mtu, err)
} else {
log.Printf("[TUN] set MTU dev=%s mtu=%d ok=true", t.Name(), mtu)
}
return err
return execCmd("ifconfig", t.Name(), "mtu", fmt.Sprintf("%d", mtu))
}
+1 -8
View File
@@ -4,7 +4,6 @@ package vpn
import (
"fmt"
"log"
"net"
)
@@ -29,11 +28,5 @@ func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error {
}
func (t *TUNInterface) SetMTU(mtu int) error {
err := execCmd("ip", "link", "set", "dev", t.Name(), "mtu", fmt.Sprintf("%d", mtu))
if err != nil {
log.Printf("[TUN] set MTU dev=%s mtu=%d ok=false err=%v", t.Name(), mtu, err)
} else {
log.Printf("[TUN] set MTU dev=%s mtu=%d ok=true", t.Name(), mtu)
}
return err
return execCmd("ip", "link", "set", "dev", t.Name(), "mtu", fmt.Sprintf("%d", mtu))
}
+43 -63
View File
@@ -21,8 +21,7 @@ const (
writeTimeout = 10 * time.Second
readyTimeout = 30 * time.Second
pingPeriod = 30 * time.Second
maxMessageSize = 1 << 20
maxConnsPerUser = 3
maxMessageSize = 1 << 20
)
var (
@@ -41,42 +40,32 @@ type tunnelConn struct {
ready atomic.Bool
rxBytes atomic.Int64
txBytes atomic.Int64
rxPkts atomic.Int64
txPkts atomic.Int64
flushedRx atomic.Int64
flushedTx atomic.Int64
}
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
func (c *tunnelConn) label() string {
s := "user=" + c.user.Username + " ip=" + c.assignedIP.String()
if c.assignedIP6 != nil {
s += " ip6=" + c.assignedIP6.String()
}
return s
func (c *tunnelConn) flushDelta() (rx, tx int64) {
curRx := c.rxBytes.Load()
curTx := c.txBytes.Load()
rx = curRx - c.flushedRx.Swap(curRx)
tx = curTx - c.flushedTx.Swap(curTx)
return
}
func (c *tunnelConn) WritePacket(data []byte) error {
if !c.ready.Load() || len(data) == 0 {
return nil
}
lockStart := time.Now()
c.writeMu.Lock()
lockWait := time.Since(lockStart)
defer c.writeMu.Unlock()
c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
writeStart := time.Now()
err := c.conn.WriteMessage(websocket.BinaryMessage, data)
writeDur := time.Since(writeStart)
if err != nil {
log.Printf("[WS-TX] %s size=%d lockWaitMs=%d writeMs=%d ok=false err=%v",
c.label(), len(data), lockWait.Milliseconds(), writeDur.Milliseconds(), err)
if err := c.conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
return err
}
c.txBytes.Add(int64(len(data)))
c.txPkts.Add(1)
log.Printf("[WS-TX] %s size=%d lockWaitMs=%d writeMs=%d ok=true",
c.label(), len(data), lockWait.Milliseconds(), writeDur.Milliseconds())
return nil
}
@@ -99,9 +88,12 @@ func (c *tunnelConn) close() {
func (c *tunnelConn) info() ClientInfo {
ci := ClientInfo{
UserID: c.user.ID,
Username: c.user.Username,
IP: c.assignedIP.String(),
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
RxBytes: c.rxBytes.Load(),
TxBytes: c.txBytes.Load(),
}
if c.assignedIP6 != nil {
ci.IP6 = c.assignedIP6.String()
@@ -118,7 +110,11 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
}
activeConnsMu.Lock()
if activeConns[user.ID] >= maxConnsPerUser {
maxConns := VPN.Settings().MaxConnsPerUser
if maxConns <= 0 {
maxConns = 30
}
if activeConns[user.ID] >= maxConns {
activeConnsMu.Unlock()
_ = sendJSON(conn, controlMessage{Type: "error", Message: "连接数已达上限"})
return
@@ -152,7 +148,8 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
VPN.registerClient(tc)
defer func() {
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load())
rx, tx := tc.flushDelta()
recordTraffic(tc.user.ID, rx, tx)
VPN.unregisterClient(tc)
}()
@@ -170,13 +167,13 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
initMsg.ServerIP6 = VPN.ServerIP6().String()
}
if err := tc.writeControl(initMsg); err != nil {
log.Printf("[CONN] init failed %s err=%v", tc.label(), err)
log.Printf("用户 %s 发送 init 失败: %v", user.Username, err)
return
}
log.Printf("[CONN] init sent %s mtu=%d prefix=%d server=%s", tc.label(), settings.MTU, VPN.Prefix(), VPN.ServerIP().String())
log.Printf("用户 %s 已连接,分配 IP %s", user.Username, ip4.String())
if ip6 != nil {
log.Printf("[CONN] init6 sent %s ip6=%s prefix6=%d server6=%s", tc.label(), ip6.String(), VPN.Prefix6(), VPN.ServerIP6().String())
log.Printf(" IPv6: %s", ip6.String())
}
conn.SetReadLimit(maxMessageSize)
@@ -190,32 +187,12 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
tc.writeMu.Lock()
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeTimeout)); err != nil {
tc.writeMu.Unlock()
log.Printf("[PING] failed %s err=%v", tc.label(), err)
return
}
tc.writeMu.Unlock()
}
}()
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var lastRx, lastTx int64
for range ticker.C {
if !tc.ready.Load() {
return
}
rx := tc.rxBytes.Load()
tx := tc.txBytes.Load()
rxRate := rx - lastRx
txRate := tx - lastTx
log.Printf("[CONN] stats %s rxBytes=%d txBytes=%d rxRate=%dB/s txRate=%dB/s rxPkts=%d txPkts=%d",
tc.label(), rx, tx, rxRate/10, txRate/10, tc.rxPkts.Load(), tc.txPkts.Load())
lastRx = rx
lastTx = tx
}
}()
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(readTimeout))
return nil
@@ -224,10 +201,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
for {
messageType, data, err := conn.ReadMessage()
if err != nil {
duration := time.Since(tc.connectedAt)
log.Printf("[CONN] disconnected %s duration=%s rxBytes=%d txBytes=%d rxPkts=%d txPkts=%d err=%v",
tc.label(), duration.Round(time.Second), tc.rxBytes.Load(), tc.txBytes.Load(),
tc.rxPkts.Load(), tc.txPkts.Load(), err)
log.Printf("用户 %s 断开连接: %v", user.Username, err)
return
}
@@ -239,7 +213,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
if msg.Type == "ready" && !tc.ready.Load() {
tc.ready.Store(true)
conn.SetReadDeadline(time.Now().Add(readTimeout))
log.Printf("[CONN] ready %s", tc.label())
log.Printf("用户 %s 就绪 (IP %s)", user.Username, ip4.String())
}
continue
}
@@ -250,7 +224,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
if !tc.ready.Load() {
if time.Now().After(readyDeadline) {
log.Printf("[CONN] ready timeout %s", tc.label())
log.Printf("用户 %s 等待 ready 超时", user.Username)
return
}
conn.SetReadDeadline(readyDeadline)
@@ -258,20 +232,14 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
}
tc.rxBytes.Add(int64(len(data)))
tc.rxPkts.Add(1)
srcIP, destIP, ok := parseIPAddrs(data)
if ok {
log.Printf("[WS-RX] %s size=%d proto=%s src=%s dst=%s",
tc.label(), len(data), protoName(data), srcIP, destIP)
} else {
log.Printf("[WS-RX] %s size=%d parse=fail", tc.label(), len(data))
}
targets := VPN.RouteFromClient(tc, data)
if isDrop(targets) {
continue
}
if len(targets) == 0 {
if err := VPN.WriteToTUN(data); err != nil {
log.Printf("[WS-RX] %s action=tun err=%v size=%d", tc.label(), err, len(data))
log.Printf("用户 %s 写入 TUN 失败: %v", user.Username, err)
}
continue
}
@@ -281,11 +249,23 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
}
}
func recordTraffic(rx, tx int64) {
func recordTraffic(userID uint, rx, tx int64) {
if rx == 0 && tx == 0 {
return
}
today := time.Now().Format("2006-01-02")
userStat := model.UserTrafficStat{UserID: userID, Date: today, RxBytes: rx, TxBytes: tx}
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gorm.Expr("rx_bytes + ?", rx),
"tx_bytes": gorm.Expr("tx_bytes + ?", tx),
}),
}).Create(&userStat).Error; err != nil {
log.Printf("记录用户流量失败: %v", err)
}
stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}},