Compare commits

...
21 Commits
Author SHA1 Message Date
kevin 8398067acb feat: 连接列表显示客户端真实 IP,支持反代和 CDN 场景
新增可配置的 real_ip_headers(默认 CF-Connecting-IP > X-Real-IP >
X-Forwarded-For 降级取值),trusted_proxies 用于 gin 代理信任链。
WebSocket 连接建立时捕获真实 IP 并在 /profile 和 /admin 连接列表展示,
登录会话 IP 同步改用真实 IP。
2026-07-10 16:58:14 +08:00
kevin a98cdb0cac fix: 禁止管理员通过 UpdateUser 禁用自己的账号 2026-07-10 14:22:16 +08:00
kevin 9fda231d47 fix: 修复踢下线竞态窗口,先禁用再踢线防重连
KickUserClient 原先先踢线再禁用账号,客户端在两步之间自动重连时
status 仍为 1 可成功认证。调换顺序为先禁用+作废会话再踢线,确保
重连时认证已被拦截。
2026-07-10 14:21:59 +08:00
kevin 50e1661c1b feat: 实现每用户流量统计与定时落库
流量统计从全站按日聚合升级为 per-user 维度,并引入 60 秒定时
落库机制,服务崩溃最多丢失 60 秒数据。

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

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

前端:
- 安装 chart.js + vue-chartjs
- 新建 TrafficChart.vue 可复用柱状图组件(上行/下行双柱)
- AdminView.vue: 在线客户端表格增加 RX/TX 列 + 全站 7 天流量
  图表 + 用户今日流量排行表
- ProfileView.vue: 新增流量统计卡片(今日上行/下行/合计)+ 7 天
  流量柱状图
- zh.ts/en.ts: 新增 traffic 相关国际化
2026-07-10 14:18:06 +08:00
kevin aad8fa9848 feat: 用户个人页面展示当前 VPN 连接信息
- handler/vpn.go: 新增 GetMyVpnConnections,返回当前用户的在线
  VPN 连接列表(IP/连接时间)及 max_conns_per_user 上限
- router.go: 注册 GET /api/me/vpn/connections(普通用户可访问)
- ProfileView.vue: 新增"我的 VPN 连接"区块,含 n/max 徽章、
  IPv4/IPv6/连接时间表格,底部提示异常连接请修改密码
- zh.ts/en.ts: 新增 myVpnConnections/noConnections/
  abnormalConnectionHint 国际化
2026-07-10 13:53:49 +08:00
kevin c63440435e feat: 每用户最大连接数改为可配置,默认 30
将 maxConnsPerUser 从硬编码常量(3)改为数据库动态配置项,管理员可在
/admin/vpn 隧道设置中调整,保存后对新连接立即生效。

- model/vpn.go: VpnSetting 新增 MaxConnsPerUser 字段,gorm default:30
- db/db.go: 种子数据设默认 30;旧库回填 0 值为 30
- vpn/tunnel.go: 删除 maxConnsPerUser 常量,改读 VPN.Settings(),兜底 30
- handler/vpn.go: API 响应/请求结构体新增字段,校验范围 1-1000
- VpnView.vue: 隧道设置表单新增"每用户最大连接数"输入框
- zh.ts/en.ts: 新增 maxConnsPerUser 文案,更新首页多设备描述
- docs/client-development.md: 更新常量表为可配置项
2026-07-10 13:44:46 +08:00
kevin f6a1fb6288 fix: 踢下线同时禁用账号防重连,禁止管理员踢自己
- handler/vpn.go: KickUserClient 踢线后设置 status=0 并失效所有会话,
  阻止客户端自动重连;新增自检,目标为当前管理员时返回 400
- AdminView.vue: handleKick 调用 API 前自检,自己则提示去修改密码
- zh.ts/en.ts: 新增 cannotKickSelf,更新 confirmKick 提示含禁用账号
2026-07-10 13:33:04 +08:00
kevin f94aa3c4ac refactor: 在线客户端卡片移至管理后台首页,移除 body 退出按钮
- AdminView.vue: 新增 fetchVpnStatus/handleKick,用户信息卡片下方
  插入在线客户端表格(含踢下线按钮),30s 轮询同步刷新;
  删除 body 退出登录按钮及 handleLogout(header 已有退出入口)
- VpnView.vue: 移除在线客户端卡片及 handleKick(已迁移至 AdminView)
2026-07-10 13:25:55 +08:00
kevin a170a234b2 fix: 管理员修改用户信息后断开对应 VPN 连接
UpdateUser 在改密/改角色/禁用用户时,失效 Web 会话的同时调用
KickUser 断开 VPN 隧道,与 ChangePassword 行为对齐。
2026-07-10 13:17:45 +08:00
kevin b8720f413d fix: 自助修改密码后断开 VPN 连接并登出所有 Web 会话
- auth.go: ChangePassword 失效所有会话(不再排除当前会话),调用 KickUser 断开 VPN 隧道
- ProfileView.vue: 改密成功后 logout 清除 token,跳转登录页并携带 msg 参数
- LoginView.vue: 读取 query.msg 显示"密码已修改,请重新登录"绿色提示
- zh.ts/en.ts: 新增 login.passwordChanged 国际化 key
2026-07-10 13:11:46 +08:00
kevin fea3fc62f7 feat: 新增管理员踢下线 VPN 客户端功能
- vpn/service.go: ClientInfo 增加 user_id 字段,新增 KickUser 方法
  用读锁收集目标连接后逐个关闭 WebSocket,返回断开数量
- vpn/tunnel.go: info() 填充 UserID
- handler/vpn.go: 新增 KickUserClient handler,校验用户存在后调用 KickUser
- router.go: 注册 DELETE /api/admin/vpn/clients/:id
- VpnView.vue: 在线客户端表格新增操作列与踢下线按钮,handleKick 确认后调用 API 并刷新
- zh.ts/en.ts: 新增 kick/confirmKick/kickFailed 国际化 key
2026-07-10 13:01:26 +08:00
kevin 129c6c7a96 fix: 密码长度上限校验 + 请求体大小限制防 DoS
- 新增 validatePassword 校验函数(6-72 字节,bcrypt 硬上限),统一应用于 ChangePassword/CreateUser/UpdateUser,超长密码返回清晰提示而非迷惑性的"密码加密失败"
- 新增 BodyLimit 中间件(http.MaxBytesReader),对所有 /api JSON 端点限制 1 MiB 请求体,防止内存耗尽型 DoS;/ws 走连接劫持不受影响
2026-07-09 15:51:10 +08:00
kevin 195769534f docs: add MIT License 2026-07-09 14:53:12 +08:00
kevin eb5d79f81e 修改默认socket路径 2026-07-09 12:36:58 +08:00
kevin 098c601687 docs: update README for dynamic firewall config and UFW support
- Remove hardcoded VPN_SUBNET/VPN_SUBNET6 variables section
- Add firewall auto-management section explaining dynamic NAT/UFW config
- Update script flow to reflect removal of manual NAT config
- Fold manual NAT config into collapsible details (usually not needed)
- Add UFW to environment requirements and diag panel description
- Update troubleshooting table with UFW-related issues
- Update vpn/ directory description to include firewall management
2026-07-08 22:13:58 +08:00
kevin 4b82340a9f fix: dynamic firewall config + UFW support + anti-spoof bug fix
Root cause: UFW FORWARD policy DROP overrides lmvpn_nat accept rules.
DNS (small UDP) worked but TCP packets were silently dropped.

- Add firewall_linux.go: dynamic NAT/forward/UFW config from ApplySettings
- Add firewall_darwin.go/firewall_other.go: stubs
- Fix anti-spoof bug in switch.go: return dropPacket sentinel instead of
  nil, so spoofed packets are no longer written to TUN
- Simplify install_linux.sh: remove hardcoded subnets and NAT config
- Add UFW detection to diag panel
2026-07-08 20:11:15 +08:00
kevin 3306fc7ee4 修改前端内容 2026-07-08 12:23:50 +08:00
kevin cbe7aeca81 修改前端内容 2026-07-08 12:16:15 +08:00
kevin 170df5f457 fix: 刷新 /admin 被错误跳转到 /profile
路由守卫在 user 信息未加载时判断权限,刷新后 token 从 localStorage
恢复但 user 为 null,导致 adminOnly 守卫误判 role !== 'admin' 而跳转。
改为在已登录但 user 为 null 时先 await fetchUser() 加载用户信息再判断。
2026-07-08 12:00:07 +08:00
kevin f16683076f docs: README 添加客户端项目地址
https://github.com/wuwenfengmi1998/lmvpn_client
2026-07-07 20:23:45 +08:00
kevin d674d40ca8 docs: 重写 README,聚焦 Linux 部署流程
- 新增一键部署、手动部署、反向代理(Caddy/Nginx)、服务管理、升级等章节
- 整合配置说明、JWT 密钥、Unix Socket 权限、首次登录初始化
- 顶部标注服务端部署仅测试 Linux 正常
- 保留目录说明与安全提示
2026-07-07 20:19:46 +08:00
38 changed files with 1856 additions and 259 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 wuwenfengmi1998
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+359 -40
View File
@@ -1,70 +1,389 @@
# lmvpn_server # LMVPN
## 目录说明 基于 WebSocket 隧道与 TUN 虚拟网卡的轻量级三层 VPN 系统,采用 Go + Vue 3 构建。服务端通过 WebSocket 与客户端建立控制通道,TUN 网卡与 WebSocket 之间双向搬运原始 IP 数据包,实现点对点与点对站点网络连接。
- `data/` - 程序运行生成的数据(配置文件、数据库文件) > **平台说明**:服务端部署**目前仅在 Linux 下测试功能正常**systemd + nftables/iptables NAT,自动兼容 UFW)。配套客户端见 [lmvpn_client](https://github.com/wuwenfengmi1998/lmvpn_client),客户端协议规范见 [`docs/client-development.md`](docs/client-development.md)。
- `dist/` - 前端构建产物,由 Go 服务端托管提供静态资源
- `frontend/` - 前端工程(Vue 3 + TypeScript + Vite
- `internal/` - Go 后端源码
- `config/` - 配置加载
- `db/` - 数据库操作
- `handler/` - HTTP 请求处理
- `middleware/` - 中间件(认证等)
- `model/` - 数据模型
- `vpn/` - VPN 相关逻辑(认证、隧道等)
- `pytest/` - Python 测试脚本
- `main.go` - Go 服务端入口文件
- `go.mod` / `go.sum` - Go 模块依赖管理
## 安全配置 ---
### JWT 密钥 ## 目录
JWT 密钥按以下优先级加载: - [环境要求](#环境要求)
- [一键部署(Linux,推荐)](#一键部署linux推荐)
- [首次登录与初始化](#首次登录与初始化)
- [手动部署](#手动部署)
- [配置说明](#配置说明)
- [反向代理(HTTPS/WSS](#反向代理httpswss)
- [服务管理](#服务管理)
- [升级](#升级)
- [目录说明](#目录说明)
- [常见问题排查](#常见问题排查)
- [相关文档](#相关文档)
- [许可证](#许可证)
1. 环境变量 `LMVPN_JWT_SECRET` ---
2. 配置文件 `data/config.yml` 中的 `web.jwt_secret`
3. 首次启动时自动生成 32 字节随机密钥并写入配置文件
生产环境建议通过环境变量注入,避免密钥落盘。 ## 环境要求
### 默认管理员 - **操作系统**:Linux(已测试),需 root 或 sudo 权限
- **Go**:≥ 1.26.4
- **Node.js**:≥ 22.18(用于构建前端)
- **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward`
- **防火墙工具**nftables(推荐)或 iptables,兼容 UFW
- **网络**:公网 IP,开放 Web 端口(或经反向代理)
首次启动时自动创建管理员账户,密码为随机生成的 16 位字符串。密码会: ---
- 打印到 stdout(仅一次 ## 一键部署(Linux,推荐
- 写入 `data/.initial_admin_password`(权限 0600
请登录后立即修改密码,并删除 `data/.initial_admin_password` 文件 仓库自带 `install_linux.sh`,一条命令完成构建、部署、内核转发配置与 systemd 服务安装。NAT / 转发 / UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置
### 登录限流 ```bash
git clone <仓库地址> lmvpn_server
cd lmvpn_server
sudo bash install_linux.sh
```
`/api/login` 和 WebSocket 密码认证均限制每 IP 5 次/分钟。 ### 脚本执行流程
### Unix Socket 权限 1. 拉取最新代码(`git fetch origin && git reset --hard origin/main`
2. 构建前端:`cd frontend && npm install && npm run build`(产物输出到 `../dist`
3. 编译后端:`go build -o lmvpn .`
4. 创建无 shell 的系统用户 `lmvpn`
5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn`
6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf`
7. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
8. 启动服务(NAT / 转发 / UFW 规则由程序在启动时根据后台子网自动配置)
默认权限兼容反向代理(如 Caddy),可通过配置文件调整: > ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。
### 防火墙规则自动管理
NAT masquerade、forward 放行、UFW 转发规则由服务端程序在 `ApplySettings()` 时根据当前后台 VPN 子网动态配置:
- 自动检测出口网卡(`ip route show default`
- 配置 nft `lmvpn_nat` 表的 postrouting masquerade 和 forward accept 规则
- 检测 UFW 是否启用(存在 `ufw-user-forward` 链),若启用则自动创建 `lmvpn-fwd` / `lmvpn6-fwd` 链并注入 jump
- 在后台修改子网后保存即可,程序自动更新规则,无需重新执行脚本
---
## 首次登录与初始化
1. **获取初始密码**:首次启动时自动创建管理员 `admin`,密码为随机 16 位字符串,会
- 打印到 stdout(仅一次)
- 写入 `/opt/lmvpn/data/.initial_admin_password`(权限 0600
```bash
sudo cat /opt/lmvpn/data/.initial_admin_password
```
2. **登录**:浏览器访问 `http://<服务器IP>:8080`,使用 `admin` + 初始密码登录。
3. **立即修改密码**,并删除初始密码文件:
```bash
sudo rm /opt/lmvpn/data/.initial_admin_password
```
4. **启用 VPN**:进入「管理后台 → VPN 管理」,确认子网(默认 `192.168.77.0/24` + IPv6 `fd00:dead:beef::/112`),打开「启用」并保存。
5. **诊断检查**:VPN 管理页的「系统环境检测」面板(对应 `GET /api/admin/vpn/diag`)会检测 ip_forward、NAT、UFW 转发规则、TUN 等,客户端无法上网时优先查看此处。
---
## 手动部署
如需自行控制部署细节,可参照以下步骤。
### 1. 构建产物
```bash
# 前端
cd frontend
npm install
npm run build # 产物输出到 ../dist
cd ..
# 后端
go build -o lmvpn .
```
### 2. 部署文件
```bash
sudo useradd -r -s /bin/false lmvpn
sudo mkdir -p /opt/lmvpn
sudo cp lmvpn /opt/lmvpn/
sudo cp -r dist /opt/lmvpn/
sudo chown -R lmvpn:lmvpn /opt/lmvpn
```
### 3. 开启内核转发
```bash
sudo sysctl -w net.ipv4.ip_forward=1
sudo sysctl -w net.ipv6.conf.all.forwarding=1 # IPv6 双栈时需要
cat <<'EOF' | sudo tee /etc/sysctl.d/99-lmvpn.conf
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-lmvpn.conf
```
### 4. 安装 systemd 服务
> NAT / 转发 / UFW 规则由服务端程序在启动时自动配置,无需手动设置。以下命令仅在程序无法自动配置防火墙时作为参考。
<details>
<summary>手动配置 NAT(点击展开,通常不需要)</summary>
将 `WAN_IFACE` 替换为出口网卡,`VPN_SUBNET` 替换为 VPN 子网:
```bash
WAN_IFACE=eth0
VPN_SUBNET=192.168.77.0/24
VPN_SUBNET6=fd00:dead:beef::/112
sudo nft add table inet lmvpn_nat
sudo nft 'add chain inet lmvpn_nat postrouting { type nat hook postrouting priority 100 ; }'
sudo nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip saddr "$VPN_SUBNET" masquerade
sudo nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip6 saddr "$VPN_SUBNET6" masquerade
sudo nft 'add chain inet lmvpn_nat forward { type filter hook forward priority 0 ; policy accept ; }'
sudo nft add rule inet lmvpn_nat forward ip saddr "$VPN_SUBNET" accept
sudo nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept
```
若启用了 UFW,还需放行 VPN 子网转发:
```bash
sudo ufw route allow from "$VPN_SUBNET"
sudo ufw route allow to "$VPN_SUBNET"
sudo ufw route allow from "$VPN_SUBNET6"
sudo ufw route allow to "$VPN_SUBNET6"
```
</details>
### 5. 安装 systemd 服务
```bash
sudo tee /etc/systemd/system/lmvpn.service >/dev/null <<'EOF'
[Unit]
Description=LMVPN Server
After=network.target
[Service]
Type=simple
User=lmvpn
WorkingDirectory=/opt/lmvpn
ExecStart=/opt/lmvpn/lmvpn
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now lmvpn
```
---
## 配置说明
配置文件位于 `data/config.yml`(相对工作目录,即 `/opt/lmvpn/data/config.yml`),首次启动时自动生成。
```yaml ```yaml
web: web:
sock: "/run/lmvpnweb.sock" port: 8080 # TCP 监听端口,0 则不监听 TCP
sock_mode: "0666" # socket 文件权限,默认 0666 sock: "/run/lmvpnweb.sock" # Unix Socket 路径,空则不监听 socket
sock_group: "" # socket 文件 group,空=不修改 sock_mode: "0666" # socket 文件权限
sock_dir_mode: "0755" # socket 目录权限,默认 0755 sock_group: "" # socket 文件属组,空=不修改
sock_dir_mode: "0755" # socket 目录权限
jwt_secret: "" # JWT 密钥,留空则按下面规则生成
database:
type: sqlite # sqlite 或 mysql
path: data/lmvpn.db # sqlite 文件路径
dsn: "" # mysql DSNtype=mysql 时必填)
``` ```
多租户/高安全场景建议收紧: > `web.port` 与 `web.sock` **至少配置一个**,两者都配则同时监听。生产环境建议仅留一个,由反向代理统一接入。
### JWT 密钥
按以下优先级加载:
1. 环境变量 `LMVPN_JWT_SECRET`
2. 配置文件 `web.jwt_secret`
3. 首次启动自动生成 32 字节随机密钥并写入配置文件
生产环境建议通过环境变量注入,避免密钥落盘:
```bash
# /etc/systemd/system/lmvpn.service 的 [Service] 段追加
Environment=LMVPN_JWT_SECRET=<你的密钥>
```
### Unix Socket 权限收紧
多租户/高安全场景建议收紧 socket 权限(将 lmvpn 进程用户加入反代用户组):
```yaml ```yaml
web: web:
sock: "/run/lmvpnweb.sock" sock: "/run/lmvpnweb.sock"
sock_mode: "0660" sock_mode: "0660"
sock_group: "caddy" # 将 lmvpn 进程用户加入 caddy group sock_group: "caddy"
sock_dir_mode: "0750" sock_dir_mode: "0750"
``` ```
### 生产部署 > 若服务以非 root 用户运行且 socket 目录不可写,可将 `sock` 指向用户可写目录(如 `/opt/lmvpn/run/lmvpnweb.sock`),或设 `sock: ""` 仅用 TCP。
---
## 反向代理(HTTPS/WSS
生产环境**必须**通过反向代理提供 HTTPS/WSS。服务端默认监听 HTTP `:8080`,由反代终结 TLS。
### Caddy(自动 HTTPS
```caddyfile
lmvpn.example.com {
reverse_proxy 127.0.0.1:8080
}
```
Caddy 的 `reverse_proxy` 自动处理 WebSocket 升级,无需额外配置。
若使用 Unix Socket 接入:
```caddyfile
lmvpn.example.com {
reverse_proxy unix//run/lmvpnweb.sock
}
```
### Nginx
```nginx
server {
listen 443 ssl http2;
server_name lmvpn.example.com;
ssl_certificate /etc/ssl/certs/lmvpn.pem;
ssl_certificate_key /etc/ssl/private/lmvpn.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
> 部署反代后,客户端应使用 `wss://lmvpn.example.com/ws` 连接。`/ws` 的长连接超时建议调大(如 3600s),避免空闲断连。
---
## 服务管理
```bash
sudo systemctl start lmvpn # 启动
sudo systemctl stop lmvpn # 停止
sudo systemctl restart lmvpn # 重启
sudo systemctl status lmvpn # 状态
sudo systemctl enable lmvpn # 开机自启
```
查看实时日志:
```bash
sudo journalctl -u lmvpn -f
```
---
## 升级
最简方式:重新执行一键脚本,它会自动拉取最新代码、重新构建并重启服务。
```bash
cd lmvpn_server
git pull
sudo bash install_linux.sh
```
> 脚本会 `git reset --hard origin/main`,本地改动会被覆盖。`/opt/lmvpn/data/` 下的配置与数据库不受影响。
---
## 目录说明
- `data/` — 运行时数据(`config.yml`、`lmvpn.db`、初始密码文件),gitignore
- `dist/` — 前端构建产物,由 Go 服务端托管,gitignore
- `frontend/` — 前端工程(Vue 3 + TypeScript + Vite
- `internal/` — Go 后端源码
- `config/` — 配置加载
- `db/` — 数据库初始化
- `handler/` — HTTP 请求处理
- `middleware/` — 中间件(认证、限流)
- `model/` — 数据模型
- `router/` — 路由
- `vpn/` - VPN 核心(认证、隧道、TUN、包转发、防火墙自动配置)
- `docs/` — 文档
- `pytest/` — Python 测试脚本
- `install_linux.sh` — Linux 一键部署脚本
- `main.go` — 服务端入口
---
## 常见问题排查
| 现象 | 可能原因 | 排查建议 |
|------|----------|----------|
| 服务启动失败 | socket 目录不可写 / 端口占用 | `journalctl -u lmvpn`;设 `sock: ""` 或换可写目录 |
| 客户端连上但无法上网 | 未开 ip_forward / NAT 未生效 / UFW 拦截 | 管理后台诊断面板查看 UFW 状态和 NAT 检测结果 |
| 客户端连上但下行极慢(几十 bps) | UFW FORWARD 默认 DROP 拦截 TCP 包 | 诊断面板检查 UFW 转发规则;重启服务使程序自动配置 UFW |
| 登录提示「请求过于频繁」 | 触发限流(`/api/login` 5 次/分钟·IP) | 等待 1 分钟后重试 |
| 修改子网后客户端异常 | 旧防火墙规则残留 | 后台重新保存设置,程序自动更新 NAT / UFW 规则 |
| 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 |
---
## 相关文档
- [LMVPN 客户端项目](https://github.com/wuwenfengmi1998/lmvpn_client) — 配套客户端源码
- [客户端开发协议规范](docs/client-development.md) — WebSocket 隧道协议、认证、握手、数据面、TUN 配置等完整规范
---
## 安全提示
- 生产环境必须通过反向代理(如 Caddy/Nginx)提供 HTTPS/WSS
- 配置文件 `data/config.yml` 权限为 0600,仅限运行用户读写 - 配置文件 `data/config.yml` 权限为 0600,仅限运行用户读写
- WebSocket `/ws` 端点支持 JWT 认证(`?token=xxx`)和密码认证两种方式 - `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟
- WebSocket `/ws` 支持 JWT`?token=xxx`)与用户名/密码两种认证
- 生产环境务必经反向代理启用 HTTPS/WSS
---
## 许可证
本项目基于 [MIT License](LICENSE) 开源。
Copyright (c) 2026 wuwenfengmi1998
+1 -1
View File
@@ -780,7 +780,7 @@ ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERA
| `readyTimeout` | 30s | 等待 ready 超时 | `internal/vpn/tunnel.go:19` | | `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` |
+30
View File
@@ -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",
+2
View File
@@ -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"
}, },
+29 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView, useRouter } from 'vue-router' import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { toggleLocale } from '@/i18n' import { toggleLocale } from '@/i18n'
import logo from '@/assets/logo.svg' import logo from '@/assets/logo.svg'
@@ -9,6 +10,20 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const versionInfo = ref<{ commit: string; commitTime: string } | null>(null)
onMounted(async () => {
try {
const res = await fetch('/api/version')
if (res.ok) {
const data = await res.json()
versionInfo.value = { commit: data.commit, commitTime: data.commit_time }
}
} catch {
// 版本信息获取失败时静默处理,footer 仅显示版权
}
})
function handleLogout() { function handleLogout() {
authStore.logout() authStore.logout()
router.push('/') router.push('/')
@@ -16,7 +31,6 @@ function handleLogout() {
const navLinks = [ const navLinks = [
{ to: '/', label: 'nav.home' }, { to: '/', label: 'nav.home' },
{ to: '/about', label: 'nav.about' },
] ]
</script> </script>
@@ -39,6 +53,17 @@ const navLinks = [
> >
{{ t(link.label) }} {{ t(link.label) }}
</RouterLink> </RouterLink>
<a
href="https://github.com/wuwenfengmi1998/lmvpn_server"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors hover:bg-sky-500/40"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path d="M12 .5C5.37.5 0 5.78 0 12.292c0 5.211 3.438 9.63 8.205 11.188.6.111.82-.254.82-.567 0-.28-.01-1.022-.015-2.005-3.338.711-4.042-1.582-4.042-1.582-.546-1.361-1.335-1.725-1.335-1.725-1.087-.731.084-.716.084-.716 1.205.082 1.838 1.215 1.838 1.215 1.07 1.803 2.809 1.282 3.495.981.108-.763.417-1.282.76-1.577-2.665-.295-5.466-1.309-5.466-5.827 0-1.287.465-2.339 1.235-3.164-.135-.298-.54-1.497.105-3.121 0 0 1.005-.316 3.3 1.209.96-.262 1.98-.392 3-.398 1.02.006 2.04.136 3 .398 2.28-1.525 3.285-1.209 3.285-1.209.645 1.624.24 2.823.12 3.121.765.825 1.23 1.877 1.23 3.164 0 4.53-2.805 5.527-5.475 5.817.42.354.81 1.077.81 2.182 0 1.578-.015 2.846-.015 3.229 0 .315.21.687.825.57C20.565 21.917 24 17.495 24 12.292 24 5.78 18.627.5 12 .5z" />
</svg>
GitHub
</a>
<template v-if="authStore.isLoggedIn"> <template v-if="authStore.isLoggedIn">
<RouterLink <RouterLink
to="/profile" to="/profile"
@@ -95,6 +120,9 @@ const navLinks = [
<footer class="bg-slate-800 text-slate-400 py-6 text-center text-sm"> <footer class="bg-slate-800 text-slate-400 py-6 text-center text-sm">
<div class="max-w-6xl mx-auto px-4"> <div class="max-w-6xl mx-auto px-4">
<p>&copy; {{ new Date().getFullYear() }} LmVPN. All rights reserved.</p> <p>&copy; {{ new Date().getFullYear() }} LmVPN. All rights reserved.</p>
<p v-if="versionInfo" class="mt-1 text-slate-500">
{{ t('footer.lastCommit') }}: {{ versionInfo.commitTime }} · {{ versionInfo.commit }}
</p>
</div> </div>
</footer> </footer>
</div> </div>
+50 -11
View File
@@ -10,17 +10,27 @@ const { t } = useI18n()
<WelcomeItem> <WelcomeItem>
<template #icon> <template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</template> </template>
<template #heading>{{ t('home.secureTunnel') }}</template> <template #heading>{{ t('home.tunnel') }}</template>
{{ t('home.secureTunnelDesc') }} {{ t('home.tunnelDesc') }}
</WelcomeItem> </WelcomeItem>
<WelcomeItem> <WelcomeItem>
<template #icon> <template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</template>
<template #heading>{{ t('home.userManage') }}</template>
{{ t('home.userManageDesc') }}
</WelcomeItem>
<WelcomeItem>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg> </svg>
</template> </template>
<template #heading>{{ t('home.multiDevice') }}</template> <template #heading>{{ t('home.multiDevice') }}</template>
@@ -30,22 +40,51 @@ const { t } = useI18n()
<WelcomeItem> <WelcomeItem>
<template #icon> <template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM3.6 9h16.8M3.6 15h16.8M11.5 3a17 17 0 000 18M12.5 3a17 17 0 010 18" />
</svg> </svg>
</template> </template>
<template #heading>{{ t('home.trafficMonitor') }}</template> <template #heading>{{ t('home.dualStack') }}</template>
{{ t('home.trafficMonitorDesc') }} {{ t('home.dualStackDesc') }}
</WelcomeItem> </WelcomeItem>
<WelcomeItem> <WelcomeItem>
<template #icon> <template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0zM15 11a3 3 0 11-6 0 3 3 0 016 0z" />
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</template> </template>
<template #heading>{{ t('home.easyConfig') }}</template> <template #heading>{{ t('home.reservation') }}</template>
{{ t('home.easyConfigDesc') }} {{ t('home.reservationDesc') }}
</WelcomeItem>
<WelcomeItem>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</template>
<template #heading>{{ t('home.traffic') }}</template>
{{ t('home.trafficDesc') }}
</WelcomeItem>
<WelcomeItem>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</template>
<template #heading>{{ t('home.session') }}</template>
{{ t('home.sessionDesc') }}
</WelcomeItem>
<WelcomeItem>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</template>
<template #heading>{{ t('home.deploy') }}</template>
{{ t('home.deployDesc') }}
</WelcomeItem> </WelcomeItem>
</div> </div>
</template> </template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const { t } = useI18n()
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const props = defineProps<{
records: TrafficRecord[]
}>()
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
const chartData = computed(() => ({
labels: props.records.map(r => r.date.slice(5)),
datasets: [
{
label: t('traffic.upload'),
data: props.records.map(r => r.rx_bytes),
backgroundColor: 'rgba(14, 165, 233, 0.6)',
borderColor: 'rgba(14, 165, 233, 1)',
borderWidth: 1,
},
{
label: t('traffic.download'),
data: props.records.map(r => r.tx_bytes),
backgroundColor: 'rgba(34, 197, 94, 0.6)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 1,
},
],
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
return `${label}: ${formatBytes(context.parsed.y)}`
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => formatBytes(Number(value)),
},
},
},
}
</script>
<template>
<div class="h-64">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+51 -13
View File
@@ -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,11 +59,14 @@ 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',
description: description:
'LmVPN is a lightweight VPN tunnel management system built with Go + Vue 3. It supports multiple VPN protocols and provides secure and reliable point-to-point and point-to-site networking solutions.', 'LmVPN is a lightweight Layer-3 VPN system built on WebSocket tunnels and TUN virtual NICs using Go + Vue 3. It supports multi-user management, IPv4/IPv6 dual-stack, static IP reservations, and traffic statistics for point-to-point and point-to-site networking.',
}, },
admin: { admin: {
title: 'Admin Dashboard', title: 'Admin Dashboard',
@@ -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,20 +166,40 @@ 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: {
lastCommit: 'Last commit',
}, },
home: { home: {
tagline: 'Secure, fast, and reliable VPN tunnel management system', tagline:
secureTunnel: 'Secure Tunnel', 'A lightweight Layer-3 VPN tunnel management system built on WebSocket and TUN virtual NICs',
secureTunnelDesc: tunnel: 'WebSocket Tunnel',
'Based on WireGuard/OpenVPN protocols with end-to-end encryption to keep your data safe.', tunnelDesc:
multiDevice: 'Multi-Device Support', 'Layer-3 tunnel over WebSocket and TUN virtual NICs, with WSS/TLS transport security via reverse proxy.',
userManage: 'Multi-User Management',
userManageDesc:
'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.',
multiDevice: 'Multi-Device Concurrent',
multiDeviceDesc: multiDeviceDesc:
'Connect multiple devices simultaneously — phones, computers, and routers all supported.', 'Supports multi-device concurrent connections per user, configurable in admin panel.',
trafficMonitor: 'Traffic Monitoring', dualStack: 'IPv4/IPv6 Dual-Stack',
trafficMonitorDesc: dualStackDesc:
'Real-time traffic statistics and bandwidth analysis to stay on top of network usage.', 'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.',
easyConfig: 'Easy Configuration', reservation: 'Static IP Reservation',
easyConfigDesc: reservationDesc:
'One-click deployment with auto-configuration — no advanced networking knowledge required.', 'Reserve fixed IPv4/IPv6 addresses per user with automatic subnet and uniqueness validation.',
traffic: 'Traffic Statistics',
trafficDesc:
'Real-time online device and traffic statistics with daily aggregated records.',
session: 'Session Management',
sessionDesc:
'JWT token sessions with self-revocation and admin force-logout, with cascading invalidation.',
deploy: 'One-Click Deploy & Diagnostics',
deployDesc:
'One-click Linux deployment script with a built-in system environment diagnostics panel.',
}, },
} }
+47 -11
View File
@@ -45,6 +45,7 @@ export default {
loginFailed: '登录失败', loginFailed: '登录失败',
loggingIn: '登录中...', loggingIn: '登录中...',
loginButton: '登录', loginButton: '登录',
passwordChanged: '密码已修改,请重新登录',
}, },
profile: { profile: {
title: '用户信息', title: '用户信息',
@@ -58,11 +59,14 @@ export default {
enterOldAndNewPassword: '请填写原密码和新密码', enterOldAndNewPassword: '请填写原密码和新密码',
passwordsDoNotMatch: '两次输入的新密码不一致', passwordsDoNotMatch: '两次输入的新密码不一致',
passwordChangeFailed: '密码修改失败', passwordChangeFailed: '密码修改失败',
myVpnConnections: '我的 VPN 连接',
noConnections: '暂无在线连接',
abnormalConnectionHint: '如发现异常连接,请修改密码以断开所有设备',
}, },
about: { about: {
title: '关于 LmVPN', title: '关于 LmVPN',
description: description:
'LmVPN 是一款轻量级 VPN 隧道管理系统,采用 Go + Vue 3 技术栈构建。支持多种 VPN 协议,提供安全可靠的点对点与点对站点网络连接方案。', 'LmVPN 是一款基于 WebSocket 隧道与 TUN 虚拟网卡的轻量级三层 VPN 系统,采用 Go + Vue 3 构建。支持多用户管理、IPv4/IPv6 双栈、静态 IP 预留与流量统计,提供点对点与点对站点网络连接方案。',
}, },
admin: { admin: {
title: '管理后台', title: '管理后台',
@@ -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,17 +165,35 @@ export default {
ipv6Address: 'IPv6 地址 (可选)', ipv6Address: 'IPv6 地址 (可选)',
selectUserAndIp: '请选择用户并至少填写一个 IP 地址', selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
confirmDeleteReservation: '确认删除该预留?', confirmDeleteReservation: '确认删除该预留?',
kick: '踢下线',
confirmKick: '确定要断开用户 {username} 的所有 VPN 连接并禁用其账号吗?',
kickFailed: '踢下线失败',
cannotKickSelf: '不能踢自己下线,如需断开自己的设备请修改密码',
},
footer: {
lastCommit: '最后提交',
}, },
home: { home: {
tagline: '安全、快速、可靠的 VPN 隧道管理系统', tagline: '基于 WebSocket 与 TUN 虚拟网卡的轻量级三层 VPN 隧道管理系统',
secureTunnel: '安全隧道', tunnel: 'WebSocket 隧道',
secureTunnelDesc: tunnelDesc:
'基于 WireGuard/OpenVPN 协议,端到端加密传输,保障数据安全无泄漏。', '基于 WebSocket 与 TUN 虚拟网卡的三层隧道,经反向代理提供 WSS/TLS 传输安全。',
multiDevice: '多设备支持', userManage: '多用户管理',
multiDeviceDesc: '同时连接多台设备,手机、电脑、路由器全平台覆盖。', userManageDesc:
trafficMonitor: '流量监控', '管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。',
trafficMonitorDesc: '实时流量统计与带宽分析,随时掌握网络使用状况。', multiDevice: '多设备并发',
easyConfig: '简易配置', multiDeviceDesc: '每用户支持多设备并发连接,可在管理后台配置上限。',
easyConfigDesc: '一键部署、自动配置,无需复杂网络知识即可上线。', dualStack: 'IPv4/IPv6 双栈',
dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。',
reservation: '静态 IP 预留',
reservationDesc:
'按用户预留固定 IPv4/IPv6 地址,自动校验子网合法性与唯一性。',
traffic: '流量统计',
trafficDesc: '实时统计在线设备与流量,按日聚合记录网络使用状况。',
session: '会话管理',
sessionDesc:
'JWT 令牌会话,支持自行注销与管理员强制下线,状态变更级联失效。',
deploy: '一键部署与诊断',
deployDesc: 'Linux 一键脚本部署,内置系统环境检测面板快速定位问题。',
}, },
} }
+6 -1
View File
@@ -47,9 +47,14 @@ const router = createRouter({
], ],
}) })
router.beforeEach((to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
// 已登录但用户信息未加载(如刷新页面后),先获取用户信息
if (authStore.isLoggedIn && !authStore.user) {
await authStore.fetchUser()
}
if (to.meta.requiresAuth && !authStore.isLoggedIn) { if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next({ name: 'login' }) next({ name: 'login' })
} else if (to.meta.adminOnly && authStore.user?.role !== 'admin') { } else if (to.meta.adminOnly && authStore.user?.role !== 'admin') {
+168 -12
View File
@@ -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>
+4 -1
View File
@@ -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"
+108
View File
@@ -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"
+8 -26
View File
@@ -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">
+11 -103
View File
@@ -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
@@ -25,7 +17,11 @@ npm run build
cd .. cd ..
echo ">>> 编译 Go 后端..." echo ">>> 编译 Go 后端..."
go build -o lmvpn . VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
COMMIT=$(git rev-parse --short HEAD)
COMMIT_TIME=$(git log -1 --format=%cI)
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
go build -ldflags "-X lmvpn/internal/version.Version=$VERSION -X lmvpn/internal/version.Commit=$COMMIT -X lmvpn/internal/version.CommitTime=$COMMIT_TIME -X lmvpn/internal/version.BuildTime=$BUILD_TIME" -o lmvpn .
echo ">>> 创建 lmvpn 系统用户..." echo ">>> 创建 lmvpn 系统用户..."
if ! id lmvpn &>/dev/null; then if ! id lmvpn &>/dev/null; then
@@ -45,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]
@@ -172,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 -7
View File
@@ -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
View File
@@ -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
} }
+26 -5
View File
@@ -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": "密码修改成功"})
} }
+255
View File
@@ -0,0 +1,255 @@
package handler
import (
"net/http"
"strconv"
"time"
"lmvpn/internal/db"
"lmvpn/internal/model"
"lmvpn/internal/vpn"
"github.com/gin-gonic/gin"
)
type userTrafficItem struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
TotalBytes int64 `json:"total_bytes"`
}
type trafficRecord struct {
Date string `json:"date"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func parseDays(c *gin.Context) int {
days := 7
if d := c.Query("days"); d != "" {
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
days = n
}
}
return days
}
func GetAdminTrafficToday(c *gin.Context) {
today := time.Now().Format("2006-01-02")
var stats []model.UserTrafficStat
db.DB.Where("date = ?", today).Find(&stats)
userIDs := make([]uint, 0, len(stats))
for _, s := range stats {
userIDs = append(userIDs, s.UserID)
}
nameMap := make(map[uint]string)
if len(userIDs) > 0 {
var users []model.User
db.DB.Where("id IN ?", userIDs).Find(&users)
for _, u := range users {
nameMap[u.ID] = u.Username
}
}
items := make([]userTrafficItem, 0, len(stats))
var totalRx, totalTx int64
seen := make(map[uint]bool)
for _, s := range stats {
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(s.UserID)
}
rx := s.RxBytes + liveRx
tx := s.TxBytes + liveTx
items = append(items, userTrafficItem{
UserID: s.UserID,
Username: nameMap[s.UserID],
RxBytes: rx,
TxBytes: tx,
TotalBytes: rx + tx,
})
totalRx += rx
totalTx += tx
seen[s.UserID] = true
}
if vpn.VPN != nil && vpn.VPN.Running() {
for _, ci := range vpn.VPN.ClientList() {
if seen[ci.UserID] {
continue
}
liveRx, liveTx := vpn.VPN.UserLiveTraffic(ci.UserID)
if liveRx == 0 && liveTx == 0 {
continue
}
var u model.User
if err := db.DB.First(&u, ci.UserID).Error; err != nil {
continue
}
items = append(items, userTrafficItem{
UserID: ci.UserID,
Username: u.Username,
RxBytes: liveRx,
TxBytes: liveTx,
TotalBytes: liveRx + liveTx,
})
totalRx += liveRx
totalTx += liveTx
seen[ci.UserID] = true
}
}
c.JSON(http.StatusOK, gin.H{
"total_rx_bytes": totalRx,
"total_tx_bytes": totalTx,
"users": items,
})
}
func GetAdminTrafficHistory(c *gin.Context) {
days := parseDays(c)
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
var stats []model.TrafficStat
db.DB.Where("date >= ?", startDate).Order("date asc").Find(&stats)
dateMap := make(map[string]trafficRecord, len(stats))
for _, s := range stats {
dateMap[s.Date] = trafficRecord{
Date: s.Date,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
}
}
out := make([]trafficRecord, 0, days)
for i := days - 1; i >= 0; i-- {
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
if r, ok := dateMap[d]; ok {
out = append(out, r)
} else {
out = append(out, trafficRecord{Date: d})
}
}
today := time.Now().Format("2006-01-02")
var todayStat model.TrafficStat
db.DB.Where("date = ?", today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.TotalLiveTraffic()
}
c.JSON(http.StatusOK, gin.H{
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"records": out,
})
}
func GetAdminUserTraffic(c *gin.Context) { idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
var user model.User
if err := db.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
days := parseDays(c)
records := queryUserTraffic(uint(id), days)
today := time.Now().Format("2006-01-02")
var todayStat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", id, today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uint(id))
}
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"username": user.Username,
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"today_live_rx": liveRx,
"today_live_tx": liveTx,
"records": records,
})
}
func GetMyTrafficToday(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
today := time.Now().Format("2006-01-02")
var stat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&stat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
}
c.JSON(http.StatusOK, gin.H{
"rx_bytes": stat.RxBytes + liveRx,
"tx_bytes": stat.TxBytes + liveTx,
})
}
func GetMyTrafficHistory(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
days := parseDays(c)
records := queryUserTraffic(uid, days)
today := time.Now().Format("2006-01-02")
var todayStat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
}
c.JSON(http.StatusOK, gin.H{
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"today_live_rx": liveRx,
"today_live_tx": liveTx,
"records": records,
})
}
func queryUserTraffic(userID uint, days int) []trafficRecord {
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
var stats []model.UserTrafficStat
db.DB.Where("user_id = ? AND date >= ?", userID, startDate).Order("date asc").Find(&stats)
dateMap := make(map[string]trafficRecord, len(stats))
for _, s := range stats {
dateMap[s.Date] = trafficRecord{
Date: s.Date,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
}
}
out := make([]trafficRecord, 0, days)
for i := days - 1; i >= 0; i-- {
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
if r, ok := dateMap[d]; ok {
out = append(out, r)
} else {
out = append(out, trafficRecord{Date: d})
}
}
return out
}
+17
View File
@@ -7,6 +7,7 @@ import (
"lmvpn/internal/db" "lmvpn/internal/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)
+25
View File
@@ -0,0 +1,25 @@
package handler
import (
"net/http"
"lmvpn/internal/version"
"github.com/gin-gonic/gin"
)
type versionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
CommitTime string `json:"commit_time"`
BuildTime string `json:"build_time"`
}
func GetVersion(c *gin.Context) {
c.JSON(http.StatusOK, versionResponse{
Version: version.Version,
Commit: version.Commit,
CommitTime: version.CommitTime,
BuildTime: version.BuildTime,
})
}
+78
View File
@@ -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})
}
+9
View File
@@ -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()
}
}
+28
View File
@@ -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
View File
@@ -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 -3
View File
@@ -14,19 +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)
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)
@@ -43,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")
+8
View File
@@ -0,0 +1,8 @@
package version
var (
Version = "dev"
Commit = "unknown"
CommitTime = "unknown"
BuildTime = "unknown"
)
+2
View File
@@ -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"`
+37
View File
@@ -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, ""
}
+13
View File
@@ -0,0 +1,13 @@
//go:build darwin
package vpn
import "log"
func configureFirewall(ipNet, ipNet6 string, tunName string) {
log.Printf("防火墙配置在当前平台不支持,请手动配置 NAT 和转发规则")
}
func checkUFWActive() bool {
return false
}
+152
View File
@@ -0,0 +1,152 @@
//go:build linux
package vpn
import (
"fmt"
"log"
"os/exec"
"strings"
)
// configureFirewall dynamically configures NAT masquerade, forward accept rules,
// and UFW forward rules based on the current VPN subnets.
// This is called from ApplySettings() so subnet changes in the backend
// are automatically reflected in firewall rules.
func configureFirewall(ipNet, ipNet6 string, tunName string) {
wanIface := detectWANInterface()
if wanIface == "" {
log.Printf("警告: 未能检测出口网卡,跳过防火墙配置")
return
}
log.Printf("出口网卡: %s", wanIface)
configureNAT(wanIface, ipNet, ipNet6)
configureForward(ipNet, ipNet6)
configureUFWForward(ipNet, ipNet6)
}
func detectWANInterface() string {
out, err := exec.Command("ip", "route", "show", "default").Output()
if err != nil {
return ""
}
fields := strings.Fields(string(out))
for i, f := range fields {
if f == "dev" && i+1 < len(fields) {
return fields[i+1]
}
}
return ""
}
func nftExists(args ...string) bool {
return exec.Command("nft", args...).Run() == nil
}
func nftExec(args ...string) {
cmd := exec.Command("nft", args...)
cmd.Stderr = nil
_ = cmd.Run()
}
func configureNAT(wanIface, ipNet, ipNet6 string) {
if !nftExists("list", "table", "inet", "lmvpn_nat") {
nftExec("add", "table", "inet", "lmvpn_nat")
}
// postrouting chain
if !nftExists("list", "chain", "inet", "lmvpn_nat", "postrouting") {
nftExec("add", "chain", "inet", "lmvpn_nat", "postrouting",
"{ type nat hook postrouting priority 100 ; }")
}
nftExec("flush", "chain", "inet", "lmvpn_nat", "postrouting")
if ipNet != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "postrouting",
"oifname", wanIface, "ip", "saddr", ipNet, "masquerade")
}
if ipNet6 != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "postrouting",
"oifname", wanIface, "ip6", "saddr", ipNet6, "masquerade")
}
log.Printf("NAT masquerade 已配置: wan=%s v4=%s", wanIface, ipNet)
if ipNet6 != "" {
log.Printf("NAT masquerade IPv6: %s", ipNet6)
}
}
func configureForward(ipNet, ipNet6 string) {
if !nftExists("list", "chain", "inet", "lmvpn_nat", "forward") {
nftExec("add", "chain", "inet", "lmvpn_nat", "forward",
"{ type filter hook forward priority 0 ; policy accept ; }")
}
nftExec("flush", "chain", "inet", "lmvpn_nat", "forward")
if ipNet != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip", "saddr", ipNet, "accept")
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip", "daddr", ipNet, "accept")
}
if ipNet6 != "" {
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip6", "saddr", ipNet6, "accept")
nftExec("add", "rule", "inet", "lmvpn_nat", "forward",
"ip6", "daddr", ipNet6, "accept")
}
}
// configureUFWForward adds VPN subnet accept rules to UFW's user-forward chains.
// UFW's FORWARD chain has policy DROP by default, which overrides the lmvpn_nat
// forward chain's accept rules. We use dedicated chains (lmvpn-fwd/lmvpn6-fwd)
// that are jumped to from ufw-user-forward/ufw6-user-forward.
func configureUFWForward(ipNet, ipNet6 string) {
// IPv4
if nftExists("list", "chain", "ip", "filter", "ufw-user-forward") {
if !nftExists("list", "chain", "ip", "filter", "lmvpn-fwd") {
nftExec("add", "chain", "ip", "filter", "lmvpn-fwd")
}
nftExec("flush", "chain", "ip", "filter", "lmvpn-fwd")
if ipNet != "" {
nftExec("add", "rule", "ip", "filter", "lmvpn-fwd",
"ip", "saddr", ipNet, "accept")
nftExec("add", "rule", "ip", "filter", "lmvpn-fwd",
"ip", "daddr", ipNet, "accept")
}
// Add jump if not already present
if !nftJumpExists("ip", "filter", "ufw-user-forward", "lmvpn-fwd") {
nftExec("add", "rule", "ip", "filter", "ufw-user-forward", "jump", "lmvpn-fwd")
}
log.Printf("UFW IPv4 转发规则已配置")
}
// IPv6
if ipNet6 != "" && nftExists("list", "chain", "ip6", "filter", "ufw6-user-forward") {
if !nftExists("list", "chain", "ip6", "filter", "lmvpn6-fwd") {
nftExec("add", "chain", "ip6", "filter", "lmvpn6-fwd")
}
nftExec("flush", "chain", "ip6", "filter", "lmvpn6-fwd")
nftExec("add", "rule", "ip6", "filter", "lmvpn6-fwd",
"ip6", "saddr", ipNet6, "accept")
nftExec("add", "rule", "ip6", "filter", "lmvpn6-fwd",
"ip6", "daddr", ipNet6, "accept")
if !nftJumpExists("ip6", "filter", "ufw6-user-forward", "lmvpn6-fwd") {
nftExec("add", "rule", "ip6", "filter", "ufw6-user-forward", "jump", "lmvpn6-fwd")
}
log.Printf("UFW IPv6 转发规则已配置")
}
}
// nftJumpExists checks if a jump rule to the target chain already exists
// in the source chain.
func nftJumpExists(family, table, chain, target string) bool {
out, err := exec.Command("nft", "list", "chain", family, table, chain).Output()
if err != nil {
return false
}
return strings.Contains(string(out), fmt.Sprintf("jump %s", target))
}
// checkUFWActive checks if UFW is active by looking for its forward chain.
func checkUFWActive() bool {
return nftExists("list", "chain", "ip", "filter", "ufw-user-forward")
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux && !darwin
package vpn
import "log"
func configureFirewall(ipNet, ipNet6 string, tunName string) {
log.Printf("防火墙配置在当前平台不支持,请手动配置 NAT 和转发规则")
}
func checkUFWActive() bool {
return false
}
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}},
+7
View File
@@ -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 == "" {