Compare commits
16
Commits
debug-logging
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8398067acb | ||
|
|
a98cdb0cac | ||
|
|
9fda231d47 | ||
|
|
50e1661c1b | ||
|
|
aad8fa9848 | ||
|
|
c63440435e | ||
|
|
f6a1fb6288 | ||
|
|
f94aa3c4ac | ||
|
|
a170a234b2 | ||
|
|
b8720f413d | ||
|
|
fea3fc62f7 | ||
|
|
129c6c7a96 | ||
|
|
195769534f | ||
|
|
eb5d79f81e | ||
|
|
098c601687 | ||
|
|
4b82340a9f |
@@ -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.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
基于 WebSocket 隧道与 TUN 虚拟网卡的轻量级三层 VPN 系统,采用 Go + Vue 3 构建。服务端通过 WebSocket 与客户端建立控制通道,TUN 网卡与 WebSocket 之间双向搬运原始 IP 数据包,实现点对点与点对站点网络连接。
|
基于 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
|
- **Go**:≥ 1.26.4
|
||||||
- **Node.js**:≥ 22.18(用于构建前端)
|
- **Node.js**:≥ 22.18(用于构建前端)
|
||||||
- **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward`
|
- **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward`
|
||||||
- **防火墙工具**:nftables(推荐)或 iptables
|
- **防火墙工具**:nftables(推荐)或 iptables,兼容 UFW
|
||||||
- **网络**:公网 IP,开放 Web 端口(或经反向代理)
|
- **网络**:公网 IP,开放 Web 端口(或经反向代理)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一键部署(Linux,推荐)
|
## 一键部署(Linux,推荐)
|
||||||
|
|
||||||
仓库自带 `install_linux.sh`,一条命令完成构建、部署、网络配置与 systemd 服务安装。
|
仓库自带 `install_linux.sh`,一条命令完成构建、部署、内核转发配置与 systemd 服务安装。NAT / 转发 / UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <仓库地址> lmvpn_server
|
git clone <仓库地址> lmvpn_server
|
||||||
@@ -51,22 +52,19 @@ sudo bash install_linux.sh
|
|||||||
4. 创建无 shell 的系统用户 `lmvpn`
|
4. 创建无 shell 的系统用户 `lmvpn`
|
||||||
5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn`
|
5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn`
|
||||||
6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf`
|
6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf`
|
||||||
7. 配置 NAT 与转发规则:自动检测出口网卡,优先 nft,回退 iptables
|
7. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
|
||||||
8. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
|
8. 启动服务(NAT / 转发 / UFW 规则由程序在启动时根据后台子网自动配置)
|
||||||
9. 启动服务并打印状态
|
|
||||||
|
|
||||||
> ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。
|
> ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。
|
||||||
|
|
||||||
### 子网变量
|
### 防火墙规则自动管理
|
||||||
|
|
||||||
脚本顶部有两个变量需与后台 VPN 设置保持一致:
|
NAT masquerade、forward 放行、UFW 转发规则由服务端程序在 `ApplySettings()` 时根据当前后台 VPN 子网动态配置:
|
||||||
|
|
||||||
```bash
|
- 自动检测出口网卡(`ip route show default`)
|
||||||
VPN_SUBNET="192.168.77.0/24" # IPv4 子网
|
- 配置 nft `lmvpn_nat` 表的 postrouting masquerade 和 forward accept 规则
|
||||||
VPN_SUBNET6="fd00:dead:beef::/112" # IPv6 子网,留空则不配置 IPv6 NAT
|
- 检测 UFW 是否启用(存在 `ufw-user-forward` 链),若启用则自动创建 `lmvpn-fwd` / `lmvpn6-fwd` 链并注入 jump
|
||||||
```
|
- 在后台修改子网后保存即可,程序自动更新规则,无需重新执行脚本
|
||||||
|
|
||||||
若在管理后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 NAT 规则,否则客户端无法上网。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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`),打开「启用」并保存。
|
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
|
sudo sysctl -p /etc/sysctl.d/99-lmvpn.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 配置 NAT(nft 示例)
|
### 4. 安装 systemd 服务
|
||||||
|
|
||||||
|
> NAT / 转发 / UFW 规则由服务端程序在启动时自动配置,无需手动设置。以下命令仅在程序无法自动配置防火墙时作为参考。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>手动配置 NAT(点击展开,通常不需要)</summary>
|
||||||
|
|
||||||
将 `WAN_IFACE` 替换为出口网卡,`VPN_SUBNET` 替换为 VPN 子网:
|
将 `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
|
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 服务
|
### 5. 安装 systemd 服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -331,7 +345,7 @@ sudo bash install_linux.sh
|
|||||||
- `middleware/` — 中间件(认证、限流)
|
- `middleware/` — 中间件(认证、限流)
|
||||||
- `model/` — 数据模型
|
- `model/` — 数据模型
|
||||||
- `router/` — 路由
|
- `router/` — 路由
|
||||||
- `vpn/` — VPN 核心(认证、隧道、TUN、包转发)
|
- `vpn/` - VPN 核心(认证、隧道、TUN、包转发、防火墙自动配置)
|
||||||
- `docs/` — 文档
|
- `docs/` — 文档
|
||||||
- `pytest/` — Python 测试脚本
|
- `pytest/` — Python 测试脚本
|
||||||
- `install_linux.sh` — Linux 一键部署脚本
|
- `install_linux.sh` — Linux 一键部署脚本
|
||||||
@@ -344,10 +358,10 @@ sudo bash install_linux.sh
|
|||||||
| 现象 | 可能原因 | 排查建议 |
|
| 现象 | 可能原因 | 排查建议 |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| 服务启动失败 | socket 目录不可写 / 端口占用 | `journalctl -u lmvpn`;设 `sock: ""` 或换可写目录 |
|
| 服务启动失败 | 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 分钟后重试 |
|
| 登录提示「请求过于频繁」 | 触发限流(`/api/login` 5 次/分钟·IP) | 等待 1 分钟后重试 |
|
||||||
| 修改子网后客户端异常 | NAT 规则仍是旧子网 | 同步脚本变量并重跑 `install_linux.sh` |
|
| 修改子网后客户端异常 | 旧防火墙规则残留 | 后台重新保存设置,程序自动更新 NAT / UFW 规则 |
|
||||||
| WebSocket 频繁断开 | 反代未透传 `Upgrade`/`Connection` 头 | 检查反代 `/ws` 配置;调大读超时 |
|
|
||||||
| 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 |
|
| 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -365,3 +379,11 @@ sudo bash install_linux.sh
|
|||||||
- `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟
|
- `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟
|
||||||
- WebSocket `/ws` 支持 JWT(`?token=xxx`)与用户名/密码两种认证
|
- WebSocket `/ws` 支持 JWT(`?token=xxx`)与用户名/密码两种认证
|
||||||
- 生产环境务必经反向代理启用 HTTPS/WSS
|
- 生产环境务必经反向代理启用 HTTPS/WSS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目基于 [MIT License](LICENSE) 开源。
|
||||||
|
|
||||||
|
Copyright (c) 2026 wuwenfengmi1998
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERA
|
|||||||
| `readyTimeout` | 30s | 等待 ready 超时 | `internal/vpn/tunnel.go:19` |
|
| `readyTimeout` | 30s | 等待 ready 超时 | `internal/vpn/tunnel.go:19` |
|
||||||
| `pingPeriod` | 30s | Ping 周期 | `internal/vpn/tunnel.go:20` |
|
| `pingPeriod` | 30s | Ping 周期 | `internal/vpn/tunnel.go:20` |
|
||||||
| `maxMessageSize` | 1 MB | 单消息上限 | `internal/vpn/tunnel.go:21` |
|
| `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` |
|
| `tokenExpire` | 24h | JWT 有效期 | `internal/middleware/auth.go:15` |
|
||||||
| 登录限流 | 5/min·IP | `/api/login` 限流 | `internal/middleware/ratelimit.go:76` |
|
| 登录限流 | 5/min·IP | `/api/login` 限流 | `internal/middleware/ratelimit.go:76` |
|
||||||
| 密码认证限流 | 5/min·(IP+用户名) | WebSocket 密码认证限流 | `internal/vpn/auth.go:15,41` |
|
| 密码认证限流 | 5/min·(IP+用户名) | WebSocket 密码认证限流 | `internal/vpn/auth.go:15,41` |
|
||||||
|
|||||||
Generated
+30
@@ -8,8 +8,10 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-i18n": "^11.4.6",
|
"vue-i18n": "^11.4.6",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
@@ -625,6 +627,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1734,6 +1742,18 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"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": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.4.6",
|
"version": "11.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
"type-check": "vue-tsc --build"
|
"type-check": "vue-tsc --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-i18n": "^11.4.6",
|
"vue-i18n": "^11.4.6",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -45,6 +45,7 @@ export default {
|
|||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
loggingIn: 'Logging in...',
|
loggingIn: 'Logging in...',
|
||||||
loginButton: 'Login',
|
loginButton: 'Login',
|
||||||
|
passwordChanged: 'Password changed, please log in again',
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: 'Profile',
|
title: 'Profile',
|
||||||
@@ -58,6 +59,9 @@ export default {
|
|||||||
enterOldAndNewPassword: 'Please enter current and new password',
|
enterOldAndNewPassword: 'Please enter current and new password',
|
||||||
passwordsDoNotMatch: 'The two passwords do not match',
|
passwordsDoNotMatch: 'The two passwords do not match',
|
||||||
passwordChangeFailed: 'Failed to change password',
|
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: {
|
about: {
|
||||||
title: 'About LmVPN',
|
title: 'About LmVPN',
|
||||||
@@ -91,6 +95,18 @@ export default {
|
|||||||
'Are you sure you want to delete user {username}? This action cannot be undone.',
|
'Are you sure you want to delete user {username}? This action cannot be undone.',
|
||||||
confirmDeleteButton: 'Confirm Delete',
|
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: {
|
vpn: {
|
||||||
title: 'VPN Management',
|
title: 'VPN Management',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
@@ -132,11 +148,13 @@ export default {
|
|||||||
serverConfigTunIp: 'Server Configures TUN IP',
|
serverConfigTunIp: 'Server Configures TUN IP',
|
||||||
autoConfig: 'Auto',
|
autoConfig: 'Auto',
|
||||||
manual: 'Manual',
|
manual: 'Manual',
|
||||||
|
maxConnsPerUser: 'Max Connections Per User',
|
||||||
saveSettings: 'Save Settings',
|
saveSettings: 'Save Settings',
|
||||||
saveSuccess: 'Saved successfully',
|
saveSuccess: 'Saved successfully',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
ipv4: 'IPv4',
|
ipv4: 'IPv4',
|
||||||
ipv6: 'IPv6',
|
ipv6: 'IPv6',
|
||||||
|
realIp: 'Real IP',
|
||||||
connectTime: 'Connected At',
|
connectTime: 'Connected At',
|
||||||
noOnlineClients: 'No online clients',
|
noOnlineClients: 'No online clients',
|
||||||
staticIpReservation: 'Static IP Reservation',
|
staticIpReservation: 'Static IP Reservation',
|
||||||
@@ -148,6 +166,10 @@ export default {
|
|||||||
ipv6Address: 'IPv6 Address (optional)',
|
ipv6Address: 'IPv6 Address (optional)',
|
||||||
selectUserAndIp: 'Please select a user and fill in at least one IP address',
|
selectUserAndIp: 'Please select a user and fill in at least one IP address',
|
||||||
confirmDeleteReservation: 'Delete this reservation?',
|
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: {
|
footer: {
|
||||||
lastCommit: 'Last commit',
|
lastCommit: 'Last commit',
|
||||||
@@ -163,7 +185,7 @@ export default {
|
|||||||
'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.',
|
'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.',
|
||||||
multiDevice: 'Multi-Device Concurrent',
|
multiDevice: 'Multi-Device Concurrent',
|
||||||
multiDeviceDesc:
|
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',
|
dualStack: 'IPv4/IPv6 Dual-Stack',
|
||||||
dualStackDesc:
|
dualStackDesc:
|
||||||
'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.',
|
'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default {
|
|||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
loggingIn: '登录中...',
|
loggingIn: '登录中...',
|
||||||
loginButton: '登录',
|
loginButton: '登录',
|
||||||
|
passwordChanged: '密码已修改,请重新登录',
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: '用户信息',
|
title: '用户信息',
|
||||||
@@ -58,6 +59,9 @@ export default {
|
|||||||
enterOldAndNewPassword: '请填写原密码和新密码',
|
enterOldAndNewPassword: '请填写原密码和新密码',
|
||||||
passwordsDoNotMatch: '两次输入的新密码不一致',
|
passwordsDoNotMatch: '两次输入的新密码不一致',
|
||||||
passwordChangeFailed: '密码修改失败',
|
passwordChangeFailed: '密码修改失败',
|
||||||
|
myVpnConnections: '我的 VPN 连接',
|
||||||
|
noConnections: '暂无在线连接',
|
||||||
|
abnormalConnectionHint: '如发现异常连接,请修改密码以断开所有设备',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: '关于 LmVPN',
|
title: '关于 LmVPN',
|
||||||
@@ -90,6 +94,18 @@ export default {
|
|||||||
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
|
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
|
||||||
confirmDeleteButton: '确认删除',
|
confirmDeleteButton: '确认删除',
|
||||||
},
|
},
|
||||||
|
traffic: {
|
||||||
|
myTraffic: '我的流量统计',
|
||||||
|
userTrafficToday: '用户今日流量',
|
||||||
|
todayTraffic: '今日流量',
|
||||||
|
upload: '上行',
|
||||||
|
download: '下行',
|
||||||
|
total: '合计',
|
||||||
|
history: '流量历史',
|
||||||
|
date: '日期',
|
||||||
|
noTrafficData: '暂无流量数据',
|
||||||
|
trafficHistory7d: '近 7 天流量',
|
||||||
|
},
|
||||||
vpn: {
|
vpn: {
|
||||||
title: 'VPN 管理',
|
title: 'VPN 管理',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
@@ -131,11 +147,13 @@ export default {
|
|||||||
serverConfigTunIp: '服务端配置 TUN IP',
|
serverConfigTunIp: '服务端配置 TUN IP',
|
||||||
autoConfig: '自动配置',
|
autoConfig: '自动配置',
|
||||||
manual: '手动',
|
manual: '手动',
|
||||||
|
maxConnsPerUser: '每用户最大连接数',
|
||||||
saveSettings: '保存设置',
|
saveSettings: '保存设置',
|
||||||
saveSuccess: '保存成功',
|
saveSuccess: '保存成功',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
ipv4: 'IPv4',
|
ipv4: 'IPv4',
|
||||||
ipv6: 'IPv6',
|
ipv6: 'IPv6',
|
||||||
|
realIp: '真实 IP',
|
||||||
connectTime: '连接时间',
|
connectTime: '连接时间',
|
||||||
noOnlineClients: '暂无在线客户端',
|
noOnlineClients: '暂无在线客户端',
|
||||||
staticIpReservation: '静态 IP 预留',
|
staticIpReservation: '静态 IP 预留',
|
||||||
@@ -147,6 +165,10 @@ export default {
|
|||||||
ipv6Address: 'IPv6 地址 (可选)',
|
ipv6Address: 'IPv6 地址 (可选)',
|
||||||
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
|
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
|
||||||
confirmDeleteReservation: '确认删除该预留?',
|
confirmDeleteReservation: '确认删除该预留?',
|
||||||
|
kick: '踢下线',
|
||||||
|
confirmKick: '确定要断开用户 {username} 的所有 VPN 连接并禁用其账号吗?',
|
||||||
|
kickFailed: '踢下线失败',
|
||||||
|
cannotKickSelf: '不能踢自己下线,如需断开自己的设备请修改密码',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
lastCommit: '最后提交',
|
lastCommit: '最后提交',
|
||||||
@@ -160,7 +182,7 @@ export default {
|
|||||||
userManageDesc:
|
userManageDesc:
|
||||||
'管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。',
|
'管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。',
|
||||||
multiDevice: '多设备并发',
|
multiDevice: '多设备并发',
|
||||||
multiDeviceDesc: '每用户最多 3 个并发连接,自动分配隧道 IP 地址。',
|
multiDeviceDesc: '每用户支持多设备并发连接,可在管理后台配置上限。',
|
||||||
dualStack: 'IPv4/IPv6 双栈',
|
dualStack: 'IPv4/IPv6 双栈',
|
||||||
dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。',
|
dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。',
|
||||||
reservation: '静态 IP 预留',
|
reservation: '静态 IP 预留',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import TrafficChart from '@/components/TrafficChart.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -19,6 +20,42 @@ const stats = ref([
|
|||||||
const userCount = ref<number | null>(null)
|
const userCount = ref<number | null>(null)
|
||||||
let statsTimer: ReturnType<typeof setInterval> | null = null
|
let statsTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
interface ClientInfo {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
ip: string
|
||||||
|
ip6?: string
|
||||||
|
real_ip: 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 {
|
function formatUptime(seconds: number): string {
|
||||||
if (seconds <= 0) return '0m'
|
if (seconds <= 0) return '0m'
|
||||||
const d = Math.floor(seconds / 86400)
|
const d = Math.floor(seconds / 86400)
|
||||||
@@ -61,11 +98,71 @@ async function fetchStats() {
|
|||||||
} catch {}
|
} 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 () => {
|
onMounted(async () => {
|
||||||
await authStore.fetchUser()
|
await authStore.fetchUser()
|
||||||
fetchUserCount()
|
fetchUserCount()
|
||||||
fetchStats()
|
fetchStats()
|
||||||
statsTimer = setInterval(fetchStats, 30000)
|
fetchVpnStatus()
|
||||||
|
fetchTrafficToday()
|
||||||
|
fetchSiteTraffic7d()
|
||||||
|
statsTimer = setInterval(() => {
|
||||||
|
fetchStats()
|
||||||
|
fetchVpnStatus()
|
||||||
|
fetchTrafficToday()
|
||||||
|
}, 30000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -75,11 +172,6 @@ onUnmounted(() => {
|
|||||||
function handleStatClick(route: string) {
|
function handleStatClick(route: string) {
|
||||||
if (route) router.push(route)
|
if (route) router.push(route)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
authStore.logout()
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -118,11 +210,75 @@ function handleLogout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
class="px-6 py-2.5 rounded-lg font-medium text-white bg-red-500 hover:bg-red-600 transition-colors"
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3>
|
||||||
@click="handleLogout"
|
<table class="w-full text-sm">
|
||||||
>
|
<thead>
|
||||||
{{ t('admin.logoutButton') }}
|
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
</button>
|
<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.realIp') }}</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="8" 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.real_ip || '-' }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const successMsg = ref(route.query.msg === 'password_changed' ? t('login.passwordChanged') : '')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@@ -71,6 +73,7 @@ async function handleLogin() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
|||||||
@@ -1,13 +1,68 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import TrafficChart from '@/components/TrafficChart.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface VpnConnection {
|
||||||
|
ip: string
|
||||||
|
ip6?: string
|
||||||
|
real_ip: 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 () => {
|
onMounted(async () => {
|
||||||
await authStore.fetchUser()
|
await authStore.fetchUser()
|
||||||
|
fetchVpnConnections()
|
||||||
|
fetchMyTraffic()
|
||||||
})
|
})
|
||||||
|
|
||||||
const showPasswordModal = ref(false)
|
const showPasswordModal = ref(false)
|
||||||
@@ -49,6 +104,8 @@ async function handleChangePassword() {
|
|||||||
throw new Error(data.error || t('profile.passwordChangeFailed'))
|
throw new Error(data.error || t('profile.passwordChangeFailed'))
|
||||||
}
|
}
|
||||||
showPasswordModal.value = false
|
showPasswordModal.value = false
|
||||||
|
authStore.logout()
|
||||||
|
router.push({ name: 'login', query: { msg: 'password_changed' } })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
passwordError.value = e.message || t('profile.passwordChangeFailed')
|
passwordError.value = e.message || t('profile.passwordChangeFailed')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,6 +126,57 @@ async function handleChangePassword() {
|
|||||||
</div>
|
</div>
|
||||||
</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.realIp') }}</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="!vpnConnections.length">
|
||||||
|
<td colspan="4" 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.real_ip || '-' }}</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>
|
||||||
|
<p class="text-xs text-gray-400 px-6 py-4">{{ t('profile.abnormalConnectionHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||||
@click="openPasswordModal"
|
@click="openPasswordModal"
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ interface Settings {
|
|||||||
allow_client_to_client: boolean
|
allow_client_to_client: boolean
|
||||||
do_local_ip_config: boolean
|
do_local_ip_config: boolean
|
||||||
do_remote_ip_config: boolean
|
do_remote_ip_config: boolean
|
||||||
|
max_conns_per_user: number
|
||||||
}
|
}
|
||||||
interface ClientInfo {
|
interface ClientInfo {
|
||||||
|
user_id: number
|
||||||
username: string
|
username: string
|
||||||
ip: string
|
ip: string
|
||||||
ip6?: string
|
ip6?: string
|
||||||
|
real_ip: string
|
||||||
connected_at: string
|
connected_at: string
|
||||||
}
|
}
|
||||||
interface Status {
|
interface Status {
|
||||||
@@ -82,6 +85,7 @@ const form = ref<Settings>({
|
|||||||
allow_client_to_client: false,
|
allow_client_to_client: false,
|
||||||
do_local_ip_config: true,
|
do_local_ip_config: true,
|
||||||
do_remote_ip_config: true,
|
do_remote_ip_config: true,
|
||||||
|
max_conns_per_user: 30,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchSettings() {
|
async function fetchSettings() {
|
||||||
@@ -402,6 +406,10 @@ onMounted(() => {
|
|||||||
<option :value="false">{{ t('vpn.manual') }}</option>
|
<option :value="false">{{ t('vpn.manual') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex items-center gap-4 mt-6">
|
<div class="flex items-center gap-4 mt-6">
|
||||||
<button
|
<button
|
||||||
@@ -415,32 +423,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
<div class="flex items-center justify-between p-6 pb-4">
|
||||||
|
|||||||
+6
-102
@@ -1,14 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
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
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh"
|
echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -49,106 +41,14 @@ chown -R lmvpn:lmvpn /opt/lmvpn
|
|||||||
echo ">>> 配置内核 IP 转发..."
|
echo ">>> 配置内核 IP 转发..."
|
||||||
# 临时生效
|
# 临时生效
|
||||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
if [ -n "$VPN_SUBNET6" ]; then
|
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null
|
||||||
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null
|
|
||||||
fi
|
|
||||||
# 持久化
|
# 持久化
|
||||||
cat > /etc/sysctl.d/99-lmvpn.conf << EOF
|
cat > /etc/sysctl.d/99-lmvpn.conf << EOF
|
||||||
net.ipv4.ip_forward = 1
|
net.ipv4.ip_forward = 1
|
||||||
|
net.ipv6.conf.all.forwarding = 1
|
||||||
EOF
|
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
|
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 服务..."
|
echo ">>> 安装 systemd 服务..."
|
||||||
cat > /etc/systemd/system/lmvpn.service << 'EOF'
|
cat > /etc/systemd/system/lmvpn.service << 'EOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -176,4 +76,8 @@ systemctl enable lmvpn
|
|||||||
systemctl restart lmvpn
|
systemctl restart lmvpn
|
||||||
|
|
||||||
echo ">>> 安装完成"
|
echo ">>> 安装完成"
|
||||||
|
echo ""
|
||||||
|
echo "NAT/转发/UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置。"
|
||||||
|
echo "若修改了后台 VPN 子网,只需在后台保存即可,程序会自动更新防火墙规则。"
|
||||||
|
echo ""
|
||||||
systemctl status lmvpn --no-pager
|
systemctl status lmvpn --no-pager
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
Sock string `yaml:"sock"`
|
Sock string `yaml:"sock"`
|
||||||
SockMode string `yaml:"sock_mode"`
|
SockMode string `yaml:"sock_mode"`
|
||||||
SockGroup string `yaml:"sock_group"`
|
SockGroup string `yaml:"sock_group"`
|
||||||
SockDirMode string `yaml:"sock_dir_mode"`
|
SockDirMode string `yaml:"sock_dir_mode"`
|
||||||
JWTSecret string `yaml:"jwt_secret"`
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
|
RealIPHeaders []string `yaml:"real_ip_headers"`
|
||||||
|
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -33,9 +35,10 @@ func defaultConfig() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Web: WebConfig{
|
Web: WebConfig{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Sock: "/run/lmvpnweb.sock",
|
Sock: "web.sock",
|
||||||
SockMode: "0666",
|
SockMode: "0666",
|
||||||
SockDirMode: "0755",
|
SockDirMode: "0755",
|
||||||
|
RealIPHeaders: []string{"CF-Connecting-IP", "X-Real-IP", "X-Forwarded-For"},
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
Type: "sqlite",
|
Type: "sqlite",
|
||||||
|
|||||||
+18
-9
@@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error {
|
|||||||
return fmt.Errorf("数据库连接失败: %w", err)
|
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)
|
return fmt.Errorf("数据库迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,21 +105,30 @@ func seedDefaultAdmin(cfg *config.DatabaseConfig) error {
|
|||||||
func seedDefaultVpnSettings() error {
|
func seedDefaultVpnSettings() error {
|
||||||
var s model.VpnSetting
|
var s model.VpnSetting
|
||||||
if err := DB.First(&s, model.VpnSettingSingletonID).Error; err == nil {
|
if err := DB.First(&s, model.VpnSettingSingletonID).Error; err == nil {
|
||||||
|
needSave := false
|
||||||
if s.Subnet6 == "" {
|
if s.Subnet6 == "" {
|
||||||
s.Subnet6 = "fd00:dead:beef::/112"
|
s.Subnet6 = "fd00:dead:beef::/112"
|
||||||
|
needSave = true
|
||||||
|
}
|
||||||
|
if s.MaxConnsPerUser == 0 {
|
||||||
|
s.MaxConnsPerUser = 30
|
||||||
|
needSave = true
|
||||||
|
}
|
||||||
|
if needSave {
|
||||||
DB.Save(&s)
|
DB.Save(&s)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s = model.VpnSetting{
|
s = model.VpnSetting{
|
||||||
ID: model.VpnSettingSingletonID,
|
ID: model.VpnSettingSingletonID,
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Subnet: "192.168.77.0/24",
|
Subnet: "192.168.77.0/24",
|
||||||
Subnet6: "fd00:dead:beef::/112",
|
Subnet6: "fd00:dead:beef::/112",
|
||||||
MTU: 1420,
|
MTU: 1420,
|
||||||
InterfaceName: "",
|
InterfaceName: "",
|
||||||
DoLocalIPConfig: true,
|
DoLocalIPConfig: true,
|
||||||
DoRemoteIPConfig: true,
|
DoRemoteIPConfig: true,
|
||||||
|
MaxConnsPerUser: 30,
|
||||||
}
|
}
|
||||||
return DB.Create(&s).Error
|
return DB.Create(&s).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lmvpn/internal/db"
|
"lmvpn/internal/db"
|
||||||
"lmvpn/internal/middleware"
|
"lmvpn/internal/middleware"
|
||||||
"lmvpn/internal/model"
|
"lmvpn/internal/model"
|
||||||
|
"lmvpn/internal/vpn"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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 {
|
type loginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
@@ -62,7 +80,7 @@ func Login(c *gin.Context) {
|
|||||||
session := model.Session{
|
session := model.Session{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
IP: c.ClientIP(),
|
IP: middleware.GetRealIP(c),
|
||||||
UserAgent: c.GetHeader("User-Agent"),
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
}
|
}
|
||||||
@@ -93,8 +111,8 @@ func ChangePassword(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.NewPassword) < 6 {
|
if err := validatePassword(req.NewPassword); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码长度不能少于6位"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +144,11 @@ func ChangePassword(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, _ := c.Get("session_id")
|
db.DB.Model(&model.Session{}).Where("user_id = ?", userID).Update("invalid", true)
|
||||||
db.DB.Model(&model.Session{}).Where("user_id = ? AND session_id != ?", userID, sessionID).Update("invalid", true)
|
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
vpn.VPN.KickUser(user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"lmvpn/internal/db"
|
"lmvpn/internal/db"
|
||||||
"lmvpn/internal/model"
|
"lmvpn/internal/model"
|
||||||
|
"lmvpn/internal/vpn"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -82,6 +83,11 @@ func CreateUser(c *gin.Context) {
|
|||||||
return
|
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)
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||||
@@ -138,6 +144,10 @@ func UpdateUser(c *gin.Context) {
|
|||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
|
if user.ID == currentUserID.(uint) && *req.Status != 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
updates["status"] = *req.Status
|
updates["status"] = *req.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +172,10 @@ func UpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Password != "" {
|
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)
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||||
@@ -183,6 +197,9 @@ func UpdateUser(c *gin.Context) {
|
|||||||
|
|
||||||
if req.Password != "" || req.Role != "" || req.Status != nil {
|
if req.Password != "" || req.Role != "" || req.Status != nil {
|
||||||
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
|
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)
|
db.DB.First(&user, id)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type vpnSettingsResponse struct {
|
|||||||
AllowClientToClient bool `json:"allow_client_to_client"`
|
AllowClientToClient bool `json:"allow_client_to_client"`
|
||||||
DoLocalIPConfig bool `json:"do_local_ip_config"`
|
DoLocalIPConfig bool `json:"do_local_ip_config"`
|
||||||
DoRemoteIPConfig bool `json:"do_remote_ip_config"`
|
DoRemoteIPConfig bool `json:"do_remote_ip_config"`
|
||||||
|
MaxConnsPerUser int `json:"max_conns_per_user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateVpnSettingsRequest struct {
|
type updateVpnSettingsRequest struct {
|
||||||
@@ -33,6 +34,7 @@ type updateVpnSettingsRequest struct {
|
|||||||
AllowClientToClient *bool `json:"allow_client_to_client"`
|
AllowClientToClient *bool `json:"allow_client_to_client"`
|
||||||
DoLocalIPConfig *bool `json:"do_local_ip_config"`
|
DoLocalIPConfig *bool `json:"do_local_ip_config"`
|
||||||
DoRemoteIPConfig *bool `json:"do_remote_ip_config"`
|
DoRemoteIPConfig *bool `json:"do_remote_ip_config"`
|
||||||
|
MaxConnsPerUser *int `json:"max_conns_per_user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadVpnSettings() (model.VpnSetting, error) {
|
func loadVpnSettings() (model.VpnSetting, error) {
|
||||||
@@ -131,6 +133,7 @@ func GetVpnSettings(c *gin.Context) {
|
|||||||
AllowClientToClient: s.AllowClientToClient,
|
AllowClientToClient: s.AllowClientToClient,
|
||||||
DoLocalIPConfig: s.DoLocalIPConfig,
|
DoLocalIPConfig: s.DoLocalIPConfig,
|
||||||
DoRemoteIPConfig: s.DoRemoteIPConfig,
|
DoRemoteIPConfig: s.DoRemoteIPConfig,
|
||||||
|
MaxConnsPerUser: s.MaxConnsPerUser,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +188,13 @@ func UpdateVpnSettings(c *gin.Context) {
|
|||||||
if req.DoRemoteIPConfig != nil {
|
if req.DoRemoteIPConfig != nil {
|
||||||
s.DoRemoteIPConfig = *req.DoRemoteIPConfig
|
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 {
|
if err := db.DB.Save(&s).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存设置失败"})
|
||||||
@@ -198,6 +208,44 @@ func UpdateVpnSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "设置已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "设置已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type myVpnConnection struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
IP6 string `json:"ip6,omitempty"`
|
||||||
|
RealIP string `json:"real_ip"`
|
||||||
|
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,
|
||||||
|
RealIP: ci.RealIP,
|
||||||
|
ConnectedAt: ci.ConnectedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"max_conns_per_user": maxConns,
|
||||||
|
"connections": connections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type vpnStatusResponse struct {
|
type vpnStatusResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Online int `json:"online"`
|
Online int `json:"online"`
|
||||||
@@ -406,3 +454,33 @@ func DeleteVpnReservation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,3 +84,12 @@ func LoginRateLimit() gin.HandlerFunc {
|
|||||||
c.Next()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var realIPHeaders []string
|
||||||
|
|
||||||
|
func SetRealIPHeaders(headers []string) {
|
||||||
|
realIPHeaders = headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRealIP(c *gin.Context) string {
|
||||||
|
for _, header := range realIPHeaders {
|
||||||
|
val := c.GetHeader(header)
|
||||||
|
if val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip := strings.TrimSpace(strings.Split(val, ",")[0])
|
||||||
|
if ip != "" && net.ParseIP(ip) != nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.ClientIP()
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type VpnSetting struct {
|
|||||||
AllowClientToClient bool `gorm:"default:false"`
|
AllowClientToClient bool `gorm:"default:false"`
|
||||||
DoLocalIPConfig bool `gorm:"default:true"`
|
DoLocalIPConfig bool `gorm:"default:true"`
|
||||||
DoRemoteIPConfig bool `gorm:"default:true"`
|
DoRemoteIPConfig bool `gorm:"default:true"`
|
||||||
|
MaxConnsPerUser int `gorm:"default:30"`
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,3 +45,16 @@ type TrafficStat struct {
|
|||||||
func (TrafficStat) TableName() string {
|
func (TrafficStat) TableName() string {
|
||||||
return "traffic_stats"
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,20 +14,25 @@ import (
|
|||||||
func Setup(r *gin.Engine) {
|
func Setup(r *gin.Engine) {
|
||||||
r.GET("/ws", vpn.HandleWS)
|
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)
|
r.GET("/api/version", handler.GetVersion)
|
||||||
|
|
||||||
auth := r.Group("/api")
|
auth := r.Group("/api")
|
||||||
auth.Use(middleware.AuthMiddleware())
|
auth.Use(middleware.AuthMiddleware(), bodyLimit)
|
||||||
{
|
{
|
||||||
auth.GET("/me", handler.Me)
|
auth.GET("/me", handler.Me)
|
||||||
auth.PUT("/me/password", handler.ChangePassword)
|
auth.PUT("/me/password", handler.ChangePassword)
|
||||||
auth.GET("/me/sessions", handler.ListMySessions)
|
auth.GET("/me/sessions", handler.ListMySessions)
|
||||||
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
|
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 := r.Group("/api/admin")
|
||||||
admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware())
|
admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware(), bodyLimit)
|
||||||
{
|
{
|
||||||
admin.GET("/stats", handler.GetAdminStats)
|
admin.GET("/stats", handler.GetAdminStats)
|
||||||
admin.GET("/users/count", handler.GetUserCount)
|
admin.GET("/users/count", handler.GetUserCount)
|
||||||
@@ -44,6 +49,11 @@ func Setup(r *gin.Engine) {
|
|||||||
admin.GET("/vpn/reservations", handler.ListVpnReservations)
|
admin.GET("/vpn/reservations", handler.ListVpnReservations)
|
||||||
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
|
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
|
||||||
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
|
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")
|
distDir := http.Dir("./dist")
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type DiagResult struct {
|
|||||||
IP6ForwardNote string `json:"ip6_forward_note,omitempty"`
|
IP6ForwardNote string `json:"ip6_forward_note,omitempty"`
|
||||||
Masquerade6 *bool `json:"masquerade6"`
|
Masquerade6 *bool `json:"masquerade6"`
|
||||||
Masquerade6Note string `json:"masquerade6_note,omitempty"`
|
Masquerade6Note string `json:"masquerade6_note,omitempty"`
|
||||||
|
UFWActive *bool `json:"ufw_active"`
|
||||||
|
UFWForwardNote string `json:"ufw_forward_note,omitempty"`
|
||||||
TUNCreate string `json:"tun_create"`
|
TUNCreate string `json:"tun_create"`
|
||||||
TUNRunning bool `json:"tun_running"`
|
TUNRunning bool `json:"tun_running"`
|
||||||
TUNName string `json:"tun_name,omitempty"`
|
TUNName string `json:"tun_name,omitempty"`
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ func fillPlatformDiag(r *DiagResult) {
|
|||||||
m6, note6 := checkMasquerade6()
|
m6, note6 := checkMasquerade6()
|
||||||
r.Masquerade6 = m6
|
r.Masquerade6 = m6
|
||||||
r.Masquerade6Note = note6
|
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 }
|
func ptrBool(b bool) *bool { return &b }
|
||||||
@@ -163,3 +172,31 @@ func checkMasquerade6() (*bool, string) {
|
|||||||
|
|
||||||
return nil, "ip6tables 与 nft 均未安装,无法检测 IPv6 NAT 规则"
|
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, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ var upgrader = websocket.Upgrader{
|
|||||||
|
|
||||||
func HandleWS(c *gin.Context) {
|
func HandleWS(c *gin.Context) {
|
||||||
tokenStr := c.Query("token")
|
tokenStr := c.Query("token")
|
||||||
|
realIP := middleware.GetRealIP(c)
|
||||||
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,11 +52,11 @@ func HandleWS(c *gin.Context) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runTunnel(conn, &u)
|
runTunnel(conn, &u, realIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := authenticate(conn, db.DB, c.ClientIP())
|
user, err := authenticate(conn, db.DB, realIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("认证读取失败: %v", err)
|
log.Printf("认证读取失败: %v", err)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -65,5 +66,5 @@ func HandleWS(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runTunnel(conn, user)
|
runTunnel(conn, user, realIP)
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-2
@@ -27,6 +27,7 @@ type VpnService struct {
|
|||||||
switchx *PacketSwitch
|
switchx *PacketSwitch
|
||||||
tun *TUNInterface
|
tun *TUNInterface
|
||||||
tunDone chan struct{}
|
tunDone chan struct{}
|
||||||
|
flushDone chan struct{}
|
||||||
running bool
|
running bool
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
clients map[*tunnelConn]struct{}
|
clients map[*tunnelConn]struct{}
|
||||||
@@ -50,16 +51,59 @@ func (s *VpnService) StartedAt() time.Time {
|
|||||||
return s.startedAt
|
return s.startedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trafficFlushPeriod = 60 * time.Second
|
||||||
|
|
||||||
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
|
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
for c := range s.clients {
|
for c := range s.clients {
|
||||||
rx += c.rxBytes.Load()
|
rx += c.rxBytes.Load() - c.flushedRx.Load()
|
||||||
tx += c.txBytes.Load()
|
tx += c.txBytes.Load() - c.flushedTx.Load()
|
||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
return
|
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 {
|
func (s *VpnService) Settings() model.VpnSetting {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -150,11 +194,21 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
|
|||||||
s.switchx = NewPacketSwitch(settings.AllowClientToClient)
|
s.switchx = NewPacketSwitch(settings.AllowClientToClient)
|
||||||
s.tun = tun
|
s.tun = tun
|
||||||
s.tunDone = make(chan struct{})
|
s.tunDone = make(chan struct{})
|
||||||
|
s.flushDone = make(chan struct{})
|
||||||
s.running = true
|
s.running = true
|
||||||
s.startedAt = time.Now()
|
s.startedAt = time.Now()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go s.serveTUN()
|
go s.serveTUN()
|
||||||
|
go s.trafficFlusher()
|
||||||
|
|
||||||
|
subnet4 := ipNet.String()
|
||||||
|
var subnet6Str string
|
||||||
|
if ipNet6 != nil {
|
||||||
|
subnet6Str = ipNet6.String()
|
||||||
|
}
|
||||||
|
configureFirewall(subnet4, subnet6Str, tun.Name())
|
||||||
|
|
||||||
log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.String())
|
log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.String())
|
||||||
if ipNet6 != nil {
|
if ipNet6 != nil {
|
||||||
log.Printf(" IPv6: subnet=%s server=%s", ipNet6.String(), serverIP6.String())
|
log.Printf(" IPv6: subnet=%s server=%s", ipNet6.String(), serverIP6.String())
|
||||||
@@ -197,10 +251,21 @@ func (s *VpnService) Stop() error {
|
|||||||
s.running = false
|
s.running = false
|
||||||
tun := s.tun
|
tun := s.tun
|
||||||
done := s.tunDone
|
done := s.tunDone
|
||||||
|
flushDone := s.flushDone
|
||||||
|
s.flushDone = nil
|
||||||
clients := s.clients
|
clients := s.clients
|
||||||
s.clients = make(map[*tunnelConn]struct{})
|
s.clients = make(map[*tunnelConn]struct{})
|
||||||
s.mu.Unlock()
|
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 {
|
for c := range clients {
|
||||||
c.close()
|
c.close()
|
||||||
}
|
}
|
||||||
@@ -366,10 +431,29 @@ func (s *VpnService) ClientList() []ClientInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ClientInfo struct {
|
type ClientInfo struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
IP6 string `json:"ip6,omitempty"`
|
IP6 string `json:"ip6,omitempty"`
|
||||||
|
RealIP string `json:"real_ip"`
|
||||||
ConnectedAt string `json:"connected_at"`
|
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
|
var VPN *VpnService
|
||||||
|
|||||||
+17
-4
@@ -106,6 +106,16 @@ func parseIPAddrs(packet []byte) (src, dest net.IP, ok bool) {
|
|||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func (s *PacketSwitch) allowC2C() bool {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
v := s.allowClientToClient
|
v := s.allowClientToClient
|
||||||
@@ -113,21 +123,24 @@ func (s *PacketSwitch) allowC2C() bool {
|
|||||||
return v
|
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 {
|
func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchConn {
|
||||||
srcIP, dest, ok := parseIPAddrs(packet)
|
srcIP, dest, ok := parseIPAddrs(packet)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return dropPacket
|
||||||
}
|
}
|
||||||
// anti-spoof: enforce assigned source IP by version
|
// anti-spoof: enforce assigned source IP by version
|
||||||
if srcIP != nil {
|
if srcIP != nil {
|
||||||
if srcIP.To4() != nil {
|
if srcIP.To4() != nil {
|
||||||
if !srcIP.Equal(src.AssignedIP()) {
|
if !srcIP.Equal(src.AssignedIP()) {
|
||||||
return nil
|
return dropPacket
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assigned6 := src.AssignedIP6()
|
assigned6 := src.AssignedIP6()
|
||||||
if assigned6 == nil || !srcIP.Equal(assigned6) {
|
if assigned6 == nil || !srcIP.Equal(assigned6) {
|
||||||
return nil
|
return dropPacket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +153,7 @@ func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchCo
|
|||||||
if s.allowC2C() {
|
if s.allowC2C() {
|
||||||
return s.allExcept(src)
|
return s.allExcept(src)
|
||||||
}
|
}
|
||||||
return nil
|
return dropPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PacketSwitch) RouteFromTUN(packet []byte) []SwitchConn {
|
func (s *PacketSwitch) RouteFromTUN(packet []byte) []SwitchConn {
|
||||||
|
|||||||
+41
-6
@@ -21,8 +21,7 @@ const (
|
|||||||
writeTimeout = 10 * time.Second
|
writeTimeout = 10 * time.Second
|
||||||
readyTimeout = 30 * time.Second
|
readyTimeout = 30 * time.Second
|
||||||
pingPeriod = 30 * time.Second
|
pingPeriod = 30 * time.Second
|
||||||
maxMessageSize = 1 << 20
|
maxMessageSize = 1 << 20
|
||||||
maxConnsPerUser = 3
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -36,16 +35,27 @@ type tunnelConn struct {
|
|||||||
svc *VpnService
|
svc *VpnService
|
||||||
assignedIP net.IP
|
assignedIP net.IP
|
||||||
assignedIP6 net.IP
|
assignedIP6 net.IP
|
||||||
|
realIP string
|
||||||
connectedAt time.Time
|
connectedAt time.Time
|
||||||
writeMu sync.Mutex
|
writeMu sync.Mutex
|
||||||
ready atomic.Bool
|
ready atomic.Bool
|
||||||
rxBytes atomic.Int64
|
rxBytes atomic.Int64
|
||||||
txBytes atomic.Int64
|
txBytes atomic.Int64
|
||||||
|
flushedRx atomic.Int64
|
||||||
|
flushedTx atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
|
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
|
||||||
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
|
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
|
||||||
|
|
||||||
|
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 {
|
func (c *tunnelConn) WritePacket(data []byte) error {
|
||||||
if !c.ready.Load() || len(data) == 0 {
|
if !c.ready.Load() || len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -79,9 +89,13 @@ func (c *tunnelConn) close() {
|
|||||||
|
|
||||||
func (c *tunnelConn) info() ClientInfo {
|
func (c *tunnelConn) info() ClientInfo {
|
||||||
ci := ClientInfo{
|
ci := ClientInfo{
|
||||||
|
UserID: c.user.ID,
|
||||||
Username: c.user.Username,
|
Username: c.user.Username,
|
||||||
IP: c.assignedIP.String(),
|
IP: c.assignedIP.String(),
|
||||||
|
RealIP: c.realIP,
|
||||||
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
RxBytes: c.rxBytes.Load(),
|
||||||
|
TxBytes: c.txBytes.Load(),
|
||||||
}
|
}
|
||||||
if c.assignedIP6 != nil {
|
if c.assignedIP6 != nil {
|
||||||
ci.IP6 = c.assignedIP6.String()
|
ci.IP6 = c.assignedIP6.String()
|
||||||
@@ -89,7 +103,7 @@ func (c *tunnelConn) info() ClientInfo {
|
|||||||
return ci
|
return ci
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTunnel(conn *websocket.Conn, user *model.User) {
|
func runTunnel(conn *websocket.Conn, user *model.User, realIP string) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
if VPN == nil || !VPN.Running() {
|
if VPN == nil || !VPN.Running() {
|
||||||
@@ -98,7 +112,11 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeConnsMu.Lock()
|
activeConnsMu.Lock()
|
||||||
if activeConns[user.ID] >= maxConnsPerUser {
|
maxConns := VPN.Settings().MaxConnsPerUser
|
||||||
|
if maxConns <= 0 {
|
||||||
|
maxConns = 30
|
||||||
|
}
|
||||||
|
if activeConns[user.ID] >= maxConns {
|
||||||
activeConnsMu.Unlock()
|
activeConnsMu.Unlock()
|
||||||
_ = sendJSON(conn, controlMessage{Type: "error", Message: "连接数已达上限"})
|
_ = sendJSON(conn, controlMessage{Type: "error", Message: "连接数已达上限"})
|
||||||
return
|
return
|
||||||
@@ -127,12 +145,14 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
svc: VPN,
|
svc: VPN,
|
||||||
assignedIP: ip4,
|
assignedIP: ip4,
|
||||||
assignedIP6: ip6,
|
assignedIP6: ip6,
|
||||||
|
realIP: realIP,
|
||||||
connectedAt: time.Now(),
|
connectedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
VPN.registerClient(tc)
|
VPN.registerClient(tc)
|
||||||
defer func() {
|
defer func() {
|
||||||
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load())
|
rx, tx := tc.flushDelta()
|
||||||
|
recordTraffic(tc.user.ID, rx, tx)
|
||||||
VPN.unregisterClient(tc)
|
VPN.unregisterClient(tc)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -217,6 +237,9 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
|
|||||||
tc.rxBytes.Add(int64(len(data)))
|
tc.rxBytes.Add(int64(len(data)))
|
||||||
|
|
||||||
targets := VPN.RouteFromClient(tc, data)
|
targets := VPN.RouteFromClient(tc, data)
|
||||||
|
if isDrop(targets) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
if err := VPN.WriteToTUN(data); err != nil {
|
if err := VPN.WriteToTUN(data); err != nil {
|
||||||
log.Printf("用户 %s 写入 TUN 失败: %v", user.Username, err)
|
log.Printf("用户 %s 写入 TUN 失败: %v", user.Username, err)
|
||||||
@@ -229,11 +252,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 {
|
if rx == 0 && tx == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
today := time.Now().Format("2006-01-02")
|
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}
|
stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
|
||||||
if err := db.DB.Clauses(clause.OnConflict{
|
if err := db.DB.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "date"}},
|
Columns: []clause.Column{{Name: "date"}},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
middleware.SetJWTSecret(cfg.Web.JWTSecret)
|
middleware.SetJWTSecret(cfg.Web.JWTSecret)
|
||||||
|
middleware.SetRealIPHeaders(cfg.Web.RealIPHeaders)
|
||||||
|
|
||||||
if err := db.Init(&cfg.Database); err != nil {
|
if err := db.Init(&cfg.Database); err != nil {
|
||||||
log.Fatalf("数据库初始化失败: %v", err)
|
log.Fatalf("数据库初始化失败: %v", err)
|
||||||
@@ -38,6 +39,12 @@ func main() {
|
|||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
if len(cfg.Web.TrustedProxies) > 0 {
|
||||||
|
_ = r.SetTrustedProxies(cfg.Web.TrustedProxies)
|
||||||
|
} else {
|
||||||
|
_ = r.SetTrustedProxies(nil)
|
||||||
|
}
|
||||||
|
|
||||||
router.Setup(r)
|
router.Setup(r)
|
||||||
|
|
||||||
if cfg.Web.Port == 0 && cfg.Web.Sock == "" {
|
if cfg.Web.Port == 0 && cfg.Web.Sock == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user