Compare commits

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