Compare 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
29 changed files with 359 additions and 676 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 数据包,实现点对点与点对站点网络连接。 基于 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 - **Go**:≥ 1.26.4
- **Node.js**:≥ 22.18(用于构建前端) - **Node.js**:≥ 22.18(用于构建前端)
- **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward` - **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward`
- **防火墙工具**nftables(推荐)或 iptables,兼容 UFW - **防火墙工具**nftables(推荐)或 iptables
- **网络**:公网 IP,开放 Web 端口(或经反向代理) - **网络**:公网 IP,开放 Web 端口(或经反向代理)
--- ---
## 一键部署(Linux,推荐) ## 一键部署(Linux,推荐)
仓库自带 `install_linux.sh`,一条命令完成构建、部署、内核转发配置与 systemd 服务安装。NAT / 转发 / UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置。 仓库自带 `install_linux.sh`,一条命令完成构建、部署、网络配置与 systemd 服务安装。
```bash ```bash
git clone <仓库地址> lmvpn_server git clone <仓库地址> lmvpn_server
@@ -52,19 +51,22 @@ sudo bash install_linux.sh
4. 创建无 shell 的系统用户 `lmvpn` 4. 创建无 shell 的系统用户 `lmvpn`
5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn` 5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn`
6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf` 6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf`
7. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW` 7. 配置 NAT 与转发规则:自动检测出口网卡,优先 nft,回退 iptables
8. 启动服务(NAT / 转发 / UFW 规则由程序在启动时根据后台子网自动配置) 8. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
9. 启动服务并打印状态
> ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。 > ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。
### 防火墙规则自动管理 ### 子网变量
NAT masquerade、forward 放行、UFW 转发规则由服务端程序在 `ApplySettings()` 时根据当前后台 VPN 子网动态配置 脚本顶部有两个变量需与后台 VPN 设置保持一致
- 自动检测出口网卡(`ip route show default` ```bash
- 配置 nft `lmvpn_nat` 表的 postrouting masquerade 和 forward accept 规则 VPN_SUBNET="192.168.77.0/24" # IPv4 子网
- 检测 UFW 是否启用(存在 `ufw-user-forward` 链),若启用则自动创建 `lmvpn-fwd` / `lmvpn6-fwd` 链并注入 jump 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`),打开「启用」并保存。 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 sudo sysctl -p /etc/sysctl.d/99-lmvpn.conf
``` ```
### 4. 安装 systemd 服务 ### 4. 配置 NATnft 示例)
> NAT / 转发 / UFW 规则由服务端程序在启动时自动配置,无需手动设置。以下命令仅在程序无法自动配置防火墙时作为参考。
<details>
<summary>手动配置 NAT(点击展开,通常不需要)</summary>
将 `WAN_IFACE` 替换为出口网卡,`VPN_SUBNET` 替换为 VPN 子网: 将 `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 sudo nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept
``` ```
若启用了 UFW,还需放行 VPN 子网转发:
```bash
sudo ufw route allow from "$VPN_SUBNET"
sudo ufw route allow to "$VPN_SUBNET"
sudo ufw route allow from "$VPN_SUBNET6"
sudo ufw route allow to "$VPN_SUBNET6"
```
</details>
### 5. 安装 systemd 服务 ### 5. 安装 systemd 服务
```bash ```bash
@@ -345,7 +331,7 @@ sudo bash install_linux.sh
- `middleware/` — 中间件(认证、限流) - `middleware/` — 中间件(认证、限流)
- `model/` — 数据模型 - `model/` — 数据模型
- `router/` — 路由 - `router/` — 路由
- `vpn/` - VPN 核心(认证、隧道、TUN、包转发、防火墙自动配置 - `vpn/` VPN 核心(认证、隧道、TUN、包转发)
- `docs/` — 文档 - `docs/` — 文档
- `pytest/` — Python 测试脚本 - `pytest/` — Python 测试脚本
- `install_linux.sh` — Linux 一键部署脚本 - `install_linux.sh` — Linux 一键部署脚本
@@ -358,10 +344,10 @@ sudo bash install_linux.sh
| 现象 | 可能原因 | 排查建议 | | 现象 | 可能原因 | 排查建议 |
|------|----------|----------| |------|----------|----------|
| 服务启动失败 | socket 目录不可写 / 端口占用 | `journalctl -u lmvpn`;设 `sock: ""` 或换可写目录 | | 服务启动失败 | socket 目录不可写 / 端口占用 | `journalctl -u lmvpn`;设 `sock: ""` 或换可写目录 |
| 客户端连上但无法上网 | 未开 ip_forward / NAT 未生效 / UFW 拦截 | 管理后台诊断面板查看 UFW 状态和 NAT 检测结果 | | 客户端连上但无法上网 | 未开 ip_forward / 未配 NAT / 子网不一致 | 管理后台诊断面板;核对脚本 `VPN_SUBNET` 与后台设置 |
| 客户端连上但下行极慢(几十 bps) | UFW FORWARD 默认 DROP 拦截 TCP 包 | 诊断面板检查 UFW 转发规则;重启服务使程序自动配置 UFW |
| 登录提示「请求过于频繁」 | 触发限流(`/api/login` 5 次/分钟·IP) | 等待 1 分钟后重试 | | 登录提示「请求过于频繁」 | 触发限流(`/api/login` 5 次/分钟·IP) | 等待 1 分钟后重试 |
| 修改子网后客户端异常 | 旧防火墙规则残留 | 后台重新保存设置,程序自动更新 NAT / UFW 规则 | | 修改子网后客户端异常 | NAT 规则仍是旧子网 | 同步脚本变量并重跑 `install_linux.sh` |
| WebSocket 频繁断开 | 反代未透传 `Upgrade`/`Connection` 头 | 检查反代 `/ws` 配置;调大读超时 |
| 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 | | 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 |
--- ---
@@ -379,11 +365,3 @@ sudo bash install_linux.sh
- `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟 - `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟
- WebSocket `/ws` 支持 JWT`?token=xxx`)与用户名/密码两种认证 - WebSocket `/ws` 支持 JWT`?token=xxx`)与用户名/密码两种认证
- 生产环境务必经反向代理启用 HTTPS/WSS - 生产环境务必经反向代理启用 HTTPS/WSS
---
## 许可证
本项目基于 [MIT License](LICENSE) 开源。
Copyright (c) 2026 wuwenfengmi1998
+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` | 30(可配置) | 单用户并发连接上限 | `internal/model/vpn.go` (`VpnSetting.MaxConnsPerUser`) | | `maxConnsPerUser` | 3 | 单用户并发连接上限 | `internal/vpn/tunnel.go:22` |
| `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` |
+1 -10
View File
@@ -45,7 +45,6 @@ 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',
@@ -59,9 +58,6 @@ 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',
@@ -136,7 +132,6 @@ 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',
@@ -153,10 +148,6 @@ export default {
ipv6Address: 'IPv6 Address (optional)', ipv6Address: 'IPv6 Address (optional)',
selectUserAndIp: 'Please select a user and fill in at least one IP address', selectUserAndIp: 'Please select a user and fill in at least one IP address',
confirmDeleteReservation: 'Delete this reservation?', confirmDeleteReservation: 'Delete this reservation?',
kick: 'Kick',
confirmKick: 'Disconnect all VPN connections for user {username} and disable their account?',
kickFailed: 'Failed to kick user',
cannotKickSelf: 'Cannot kick yourself. To disconnect your own devices, please change your password.',
}, },
footer: { footer: {
lastCommit: 'Last commit', lastCommit: 'Last commit',
@@ -172,7 +163,7 @@ export default {
'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.', 'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.',
multiDevice: 'Multi-Device Concurrent', multiDevice: 'Multi-Device Concurrent',
multiDeviceDesc: multiDeviceDesc:
'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', dualStack: 'IPv4/IPv6 Dual-Stack',
dualStackDesc: dualStackDesc:
'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.', 'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.',
+1 -10
View File
@@ -45,7 +45,6 @@ export default {
loginFailed: '登录失败', loginFailed: '登录失败',
loggingIn: '登录中...', loggingIn: '登录中...',
loginButton: '登录', loginButton: '登录',
passwordChanged: '密码已修改,请重新登录',
}, },
profile: { profile: {
title: '用户信息', title: '用户信息',
@@ -59,9 +58,6 @@ export default {
enterOldAndNewPassword: '请填写原密码和新密码', enterOldAndNewPassword: '请填写原密码和新密码',
passwordsDoNotMatch: '两次输入的新密码不一致', passwordsDoNotMatch: '两次输入的新密码不一致',
passwordChangeFailed: '密码修改失败', passwordChangeFailed: '密码修改失败',
myVpnConnections: '我的 VPN 连接',
noConnections: '暂无在线连接',
abnormalConnectionHint: '如发现异常连接,请修改密码以断开所有设备',
}, },
about: { about: {
title: '关于 LmVPN', title: '关于 LmVPN',
@@ -135,7 +131,6 @@ export default {
serverConfigTunIp: '服务端配置 TUN IP', serverConfigTunIp: '服务端配置 TUN IP',
autoConfig: '自动配置', autoConfig: '自动配置',
manual: '手动', manual: '手动',
maxConnsPerUser: '每用户最大连接数',
saveSettings: '保存设置', saveSettings: '保存设置',
saveSuccess: '保存成功', saveSuccess: '保存成功',
user: '用户', user: '用户',
@@ -152,10 +147,6 @@ export default {
ipv6Address: 'IPv6 地址 (可选)', ipv6Address: 'IPv6 地址 (可选)',
selectUserAndIp: '请选择用户并至少填写一个 IP 地址', selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
confirmDeleteReservation: '确认删除该预留?', confirmDeleteReservation: '确认删除该预留?',
kick: '踢下线',
confirmKick: '确定要断开用户 {username} 的所有 VPN 连接并禁用其账号吗?',
kickFailed: '踢下线失败',
cannotKickSelf: '不能踢自己下线,如需断开自己的设备请修改密码',
}, },
footer: { footer: {
lastCommit: '最后提交', lastCommit: '最后提交',
@@ -169,7 +160,7 @@ export default {
userManageDesc: userManageDesc:
'管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。', '管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。',
multiDevice: '多设备并发', multiDevice: '多设备并发',
multiDeviceDesc: '每用户支持多设备并发连接,可在管理后台配置上限。', multiDeviceDesc: '每用户最多 3 个并发连接,自动分配隧道 IP 地址。',
dualStack: 'IPv4/IPv6 双栈', dualStack: 'IPv4/IPv6 双栈',
dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。', dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。',
reservation: '静态 IP 预留', reservation: '静态 IP 预留',
+12 -80
View File
@@ -19,16 +19,6 @@ 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
connected_at: string
}
const vpnClients = ref<ClientInfo[]>([])
const kickError = ref('')
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)
@@ -71,46 +61,11 @@ 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 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()
fetchVpnStatus() statsTimer = setInterval(fetchStats, 30000)
statsTimer = setInterval(() => {
fetchStats()
fetchVpnStatus()
}, 30000)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -120,6 +75,11 @@ 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>
@@ -158,39 +118,11 @@ function handleStatClick(route: string) {
</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 mb-6"> <button
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3> class="px-6 py-2.5 rounded-lg font-medium text-white bg-red-500 hover:bg-red-600 transition-colors"
<table class="w-full text-sm"> @click="handleLogout"
<thead> >
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> {{ t('admin.logoutButton') }}
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th> </button>
<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-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!vpnClients.length">
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
</tr>
<tr v-for="(c, i) in vpnClients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
<td class="px-6 py-3">
<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> </div>
</template> </template>
+1 -4
View File
@@ -1,18 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } 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'
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() {
@@ -73,7 +71,6 @@ 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"
-54
View File
@@ -1,36 +1,13 @@
<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'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
interface VpnConnection {
ip: string
ip6?: string
connected_at: string
}
const vpnConnections = ref<VpnConnection[]>([])
const maxConns = ref(30)
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()
}) })
const showPasswordModal = ref(false) const showPasswordModal = ref(false)
@@ -72,8 +49,6 @@ 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 {
@@ -94,35 +69,6 @@ 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 overflow-hidden mb-6">
<div class="flex items-center justify-between p-6 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.myVpnConnections') }}</h3>
<span class="px-3 py-1 text-sm font-medium rounded-full" :class="vpnConnections.length > 0 ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'">
{{ vpnConnections.length }} / {{ maxConns }}
</span>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!vpnConnections.length">
<td colspan="3" class="px-6 py-6 text-center text-gray-400">{{ t('profile.noConnections') }}</td>
</tr>
<tr v-for="(c, i) in vpnConnections" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
</tr>
</tbody>
</table>
<p class="text-xs text-gray-400 px-6 py-4">{{ t('profile.abnormalConnectionHint') }}</p>
</div>
<button <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"
+26 -7
View File
@@ -16,10 +16,8 @@ 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
@@ -84,7 +82,6 @@ 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() {
@@ -405,10 +402,6 @@ 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
@@ -422,6 +415,32 @@ 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">
+109 -9
View File
@@ -1,14 +1,26 @@
#!/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
fi fi
echo ">>> 拉取最新代码(强制覆盖本地修改)..." # 部署分支(默认 main,调试时可改为其他分支如 debug-logging
git fetch origin DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
git reset --hard origin/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 ">>> 安装前端依赖并构建..." echo ">>> 安装前端依赖并构建..."
cd frontend cd frontend
@@ -41,14 +53,106 @@ 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
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 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]
@@ -76,8 +180,4 @@ 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
+1 -1
View File
@@ -33,7 +33,7 @@ func defaultConfig() *Config {
return &Config{ return &Config{
Web: WebConfig{ Web: WebConfig{
Port: 8080, Port: 8080,
Sock: "web.sock", Sock: "/run/lmvpnweb.sock",
SockMode: "0666", SockMode: "0666",
SockDirMode: "0755", SockDirMode: "0755",
}, },
+8 -17
View File
@@ -105,30 +105,21 @@ 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
} }
+4 -25
View File
@@ -1,36 +1,18 @@
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"`
@@ -111,8 +93,8 @@ func ChangePassword(c *gin.Context) {
return return
} }
if err := validatePassword(req.NewPassword); err != nil { if len(req.NewPassword) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "新密码长度不能少于6位"})
return return
} }
@@ -144,11 +126,8 @@ func ChangePassword(c *gin.Context) {
return return
} }
db.DB.Model(&model.Session{}).Where("user_id = ?", userID).Update("invalid", true) sessionID, _ := c.Get("session_id")
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": "密码修改成功"})
} }
-13
View File
@@ -7,7 +7,6 @@ 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"
@@ -83,11 +82,6 @@ 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": "密码加密失败"})
@@ -168,10 +162,6 @@ 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": "密码加密失败"})
@@ -193,9 +183,6 @@ 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)
-77
View File
@@ -22,7 +22,6 @@ 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 {
@@ -34,7 +33,6 @@ 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) {
@@ -133,7 +131,6 @@ 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,
}) })
} }
@@ -188,13 +185,6 @@ 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": "保存设置失败"})
@@ -208,42 +198,6 @@ 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"`
ConnectedAt string `json:"connected_at"`
}
func GetMyVpnConnections(c *gin.Context) {
userID, _ := c.Get("user_id")
maxConns := 30
if vpn.VPN != nil {
s := vpn.VPN.Settings()
if s.MaxConnsPerUser > 0 {
maxConns = s.MaxConnsPerUser
}
}
connections := make([]myVpnConnection, 0)
if vpn.VPN != nil && vpn.VPN.Running() {
for _, ci := range vpn.VPN.ClientList() {
if ci.UserID == userID.(uint) {
connections = append(connections, myVpnConnection{
IP: ci.IP,
IP6: ci.IP6,
ConnectedAt: ci.ConnectedAt,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"max_conns_per_user": maxConns,
"connections": connections,
})
}
type vpnStatusResponse struct { type vpnStatusResponse struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Online int `json:"online"` Online int `json:"online"`
@@ -452,34 +406,3 @@ 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
if vpn.VPN != nil && vpn.VPN.Running() {
n = vpn.VPN.KickUser(uint(id))
}
db.DB.Model(&user).Update("status", 0)
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
c.JSON(http.StatusOK, gin.H{"message": "已断开用户连接并禁用账号", "kicked": n})
}
-9
View File
@@ -84,12 +84,3 @@ 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()
}
}
-1
View File
@@ -14,7 +14,6 @@ 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
} }
+3 -7
View File
@@ -14,23 +14,20 @@ import (
func Setup(r *gin.Engine) { func Setup(r *gin.Engine) {
r.GET("/ws", vpn.HandleWS) r.GET("/ws", vpn.HandleWS)
bodyLimit := middleware.BodyLimit(1 << 20) // 1 MiB r.POST("/api/login", middleware.LoginRateLimit(), handler.Login)
r.POST("/api/login", bodyLimit, middleware.LoginRateLimit(), handler.Login)
r.GET("/api/version", handler.GetVersion) r.GET("/api/version", handler.GetVersion)
auth := r.Group("/api") auth := r.Group("/api")
auth.Use(middleware.AuthMiddleware(), bodyLimit) auth.Use(middleware.AuthMiddleware())
{ {
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)
} }
admin := r.Group("/api/admin") 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("/stats", handler.GetAdminStats)
admin.GET("/users/count", handler.GetUserCount) admin.GET("/users/count", handler.GetUserCount)
@@ -47,7 +44,6 @@ 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)
} }
distDir := http.Dir("./dist") distDir := http.Dir("./dist")
-2
View File
@@ -18,8 +18,6 @@ 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,15 +34,6 @@ 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 }
@@ -172,31 +163,3 @@ 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
@@ -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
}
+6 -3
View File
@@ -31,14 +31,16 @@ var upgrader = websocket.Upgrader{
func HandleWS(c *gin.Context) { func HandleWS(c *gin.Context) {
tokenStr := c.Query("token") tokenStr := c.Query("token")
clientIP := c.ClientIP()
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
log.Printf("WebSocket 升级失败: %v", err) log.Printf("[WS] upgrade failed clientIP=%s err=%v", clientIP, err)
return return
} }
if tokenStr != "" { if tokenStr != "" {
log.Printf("[WS] upgrade clientIP=%s auth=jwt", clientIP)
claims, err := middleware.ParseToken(tokenStr) claims, err := middleware.ParseToken(tokenStr)
if err != nil { if err != nil {
sendJSON(conn, authResponse{Type: "auth_err", Message: "令牌无效或已过期"}) sendJSON(conn, authResponse{Type: "auth_err", Message: "令牌无效或已过期"})
@@ -55,9 +57,10 @@ func HandleWS(c *gin.Context) {
return return
} }
user, err := authenticate(conn, db.DB, c.ClientIP()) log.Printf("[WS] upgrade clientIP=%s auth=password", clientIP)
user, err := authenticate(conn, db.DB, clientIP)
if err != nil { if err != nil {
log.Printf("认证读取失败: %v", err) log.Printf("[WS] auth read failed clientIP=%s err=%v", clientIP, err)
conn.Close() conn.Close()
return return
} }
+48 -31
View File
@@ -155,18 +155,13 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
s.mu.Unlock() s.mu.Unlock()
go s.serveTUN() go s.serveTUN()
go s.startStatsLogger()
subnet4 := ipNet.String() log.Printf("[TUN] created name=%s mtu=%d", tun.Name(), settings.MTU)
var subnet6Str string log.Printf("[TUN] ipv4 addr=%s/%d subnet=%s server=%s", serverIP.String(), prefix, ipNet.String(), serverIP.String())
if ipNet6 != nil { if ipNet6 != nil {
subnet6Str = ipNet6.String() log.Printf("[TUN] ipv6 addr=%s/%d subnet=%s server=%s", serverIP6.String(), prefix6, ipNet6.String(), serverIP6.String())
}
configureFirewall(subnet4, subnet6Str, tun.Name())
log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.String())
if ipNet6 != nil {
log.Printf(" IPv6: subnet=%s server=%s", ipNet6.String(), serverIP6.String())
} }
log.Printf("[VPN] started c2c=%v bufSize=%d", settings.AllowClientToClient, settings.MTU+64)
return nil return nil
} }
@@ -182,17 +177,49 @@ func (s *VpnService) serveTUN() {
for { for {
n, err := tun.Iface.Read(packet) n, err := tun.Iface.Read(packet)
if err != nil { if err != nil {
log.Printf("TUN 读取结束: %v", err) log.Printf("[TUN-RX] read ended: %v", err)
close(done) close(done)
return return
} }
if n < 1 { if n < 1 {
continue continue
} }
targets := switchx.RouteFromTUN(packet[:n]) pkt := packet[:n]
for _, t := range targets { srcIP, destIP, ok := parseIPAddrs(pkt)
_ = t.WritePacket(packet[:n]) 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
} }
} }
@@ -218,7 +245,7 @@ func (s *VpnService) Stop() error {
<-done <-done
} }
} }
log.Printf("VPN 服务已停止") log.Printf("[VPN] stopped")
return nil return nil
} }
@@ -250,9 +277,15 @@ func (s *VpnService) WriteToTUN(packet []byte) error {
tun := s.tun tun := s.tun
s.mu.RUnlock() s.mu.RUnlock()
if tun == nil { if tun == nil {
log.Printf("[TUN-TX] size=%d ok=false err=TUN not ready", len(packet))
return errors.New("TUN 未就绪") return errors.New("TUN 未就绪")
} }
_, err := tun.Iface.Write(packet) _, 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 return err
} }
@@ -374,26 +407,10 @@ 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"`
ConnectedAt string `json:"connected_at"` ConnectedAt string `json:"connected_at"`
} }
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
+39 -17
View File
@@ -1,6 +1,7 @@
package vpn package vpn
import ( import (
"log"
"net" "net"
"sync" "sync"
@@ -106,14 +107,14 @@ 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 func protoName(packet []byte) string {
// to silently drop the packet (do not write to TUN, do not relay). if waterutil.IsIPv4(packet) {
var dropPacket = []SwitchConn{nil} return "IPv4"
}
// isDrop returns true if the result from RouteFromClient indicates the packet if waterutil.IsIPv6(packet) {
// should be silently dropped rather than forwarded to TUN or relayed. return "IPv6"
func isDrop(targets []SwitchConn) bool { }
return len(targets) == 1 && targets[0] == nil return "unknown"
} }
func (s *PacketSwitch) allowC2C() bool { func (s *PacketSwitch) allowC2C() bool {
@@ -123,49 +124,70 @@ 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 dropPacket log.Printf("[ROUTE] client->? parse failed size=%d", len(packet))
return nil
} }
proto := protoName(packet)
// 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 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 { } else {
assigned6 := src.AssignedIP6() assigned6 := src.AssignedIP6()
if assigned6 == nil || !srcIP.Equal(assigned6) { 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 dest.IsGlobalUnicast() {
if c := s.findByIP(dest); c != nil && s.allowC2C() { 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} return []SwitchConn{c}
} }
log.Printf("[ROUTE] client->tun src=%s dst=%s proto=%s size=%d",
src.AssignedIP(), dest, proto, len(packet))
return nil return nil
} }
if s.allowC2C() { 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 { func (s *PacketSwitch) RouteFromTUN(packet []byte) []SwitchConn {
_, dest, ok := parseIPAddrs(packet) _, dest, ok := parseIPAddrs(packet)
if !ok { if !ok {
log.Printf("[ROUTE] TUN->? parse failed size=%d", len(packet))
return nil return nil
} }
proto := protoName(packet)
if dest.IsGlobalUnicast() { if dest.IsGlobalUnicast() {
if c := s.findByIP(dest); c != nil { 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} return []SwitchConn{c}
} }
log.Printf("[ROUTE] TUN->client dst=%s proto=%s size=%d found=false",
dest, proto, len(packet))
return nil 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 ( import (
"fmt" "fmt"
"log"
"net" "net"
) )
@@ -32,5 +33,11 @@ func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error {
} }
func (t *TUNInterface) SetMTU(mtu int) 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 ( import (
"fmt" "fmt"
"log"
"net" "net"
) )
@@ -28,5 +29,11 @@ func (t *TUNInterface) AddSubnetRoute(subnet *net.IPNet) error {
} }
func (t *TUNInterface) SetMTU(mtu int) 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 -18
View File
@@ -21,7 +21,8 @@ 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 (
@@ -40,22 +41,42 @@ type tunnelConn struct {
ready atomic.Bool ready atomic.Bool
rxBytes atomic.Int64 rxBytes atomic.Int64
txBytes atomic.Int64 txBytes atomic.Int64
rxPkts atomic.Int64
txPkts 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) 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 { 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
} }
lockStart := time.Now()
c.writeMu.Lock() c.writeMu.Lock()
lockWait := time.Since(lockStart)
defer c.writeMu.Unlock() defer c.writeMu.Unlock()
c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) 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 return err
} }
c.txBytes.Add(int64(len(data))) 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 return nil
} }
@@ -78,7 +99,6 @@ 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(),
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"), ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
@@ -98,11 +118,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
} }
activeConnsMu.Lock() activeConnsMu.Lock()
maxConns := VPN.Settings().MaxConnsPerUser if activeConns[user.ID] >= 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
@@ -154,13 +170,13 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
initMsg.ServerIP6 = VPN.ServerIP6().String() initMsg.ServerIP6 = VPN.ServerIP6().String()
} }
if err := tc.writeControl(initMsg); err != nil { 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 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 { 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) conn.SetReadLimit(maxMessageSize)
@@ -174,12 +190,32 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
tc.writeMu.Lock() tc.writeMu.Lock()
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeTimeout)); err != nil { if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeTimeout)); err != nil {
tc.writeMu.Unlock() tc.writeMu.Unlock()
log.Printf("[PING] failed %s err=%v", tc.label(), err)
return return
} }
tc.writeMu.Unlock() 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.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(readTimeout)) conn.SetReadDeadline(time.Now().Add(readTimeout))
return nil return nil
@@ -188,7 +224,10 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
for { for {
messageType, data, err := conn.ReadMessage() messageType, data, err := conn.ReadMessage()
if err != nil { 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 return
} }
@@ -200,7 +239,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
if msg.Type == "ready" && !tc.ready.Load() { if msg.Type == "ready" && !tc.ready.Load() {
tc.ready.Store(true) tc.ready.Store(true)
conn.SetReadDeadline(time.Now().Add(readTimeout)) conn.SetReadDeadline(time.Now().Add(readTimeout))
log.Printf("用户 %s 就绪 (IP %s)", user.Username, ip4.String()) log.Printf("[CONN] ready %s", tc.label())
} }
continue continue
} }
@@ -211,7 +250,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
if !tc.ready.Load() { if !tc.ready.Load() {
if time.Now().After(readyDeadline) { if time.Now().After(readyDeadline) {
log.Printf("用户 %s 等待 ready 超时", user.Username) log.Printf("[CONN] ready timeout %s", tc.label())
return return
} }
conn.SetReadDeadline(readyDeadline) conn.SetReadDeadline(readyDeadline)
@@ -219,14 +258,20 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
} }
tc.rxBytes.Add(int64(len(data))) 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) 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("[WS-RX] %s action=tun err=%v size=%d", tc.label(), err, len(data))
} }
continue continue
} }