Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d08dffa39 | ||
|
|
1dce2bb99d | ||
|
|
616d30bc08 | ||
|
|
3306fc7ee4 | ||
|
|
cbe7aeca81 | ||
|
|
170df5f457 | ||
|
|
f16683076f | ||
|
|
d674d40ca8 |
@@ -1,70 +1,367 @@
|
|||||||
# lmvpn_server
|
# LMVPN
|
||||||
|
|
||||||
## 目录说明
|
基于 WebSocket 隧道与 TUN 虚拟网卡的轻量级三层 VPN 系统,采用 Go + Vue 3 构建。服务端通过 WebSocket 与客户端建立控制通道,TUN 网卡与 WebSocket 之间双向搬运原始 IP 数据包,实现点对点与点对站点网络连接。
|
||||||
|
|
||||||
- `data/` - 程序运行生成的数据(配置文件、数据库文件)
|
> **平台说明**:服务端部署**目前仅在 Linux 下测试功能正常**(systemd + nftables/iptables NAT)。配套客户端见 [lmvpn_client](https://github.com/wuwenfengmi1998/lmvpn_client),客户端协议规范见 [`docs/client-development.md`](docs/client-development.md)。
|
||||||
- `dist/` - 前端构建产物,由 Go 服务端托管提供静态资源
|
|
||||||
- `frontend/` - 前端工程(Vue 3 + TypeScript + Vite)
|
|
||||||
- `internal/` - Go 后端源码
|
|
||||||
- `config/` - 配置加载
|
|
||||||
- `db/` - 数据库操作
|
|
||||||
- `handler/` - HTTP 请求处理
|
|
||||||
- `middleware/` - 中间件(认证等)
|
|
||||||
- `model/` - 数据模型
|
|
||||||
- `vpn/` - VPN 相关逻辑(认证、隧道等)
|
|
||||||
- `pytest/` - Python 测试脚本
|
|
||||||
- `main.go` - Go 服务端入口文件
|
|
||||||
- `go.mod` / `go.sum` - Go 模块依赖管理
|
|
||||||
|
|
||||||
## 安全配置
|
---
|
||||||
|
|
||||||
### JWT 密钥
|
## 目录
|
||||||
|
|
||||||
JWT 密钥按以下优先级加载:
|
- [环境要求](#环境要求)
|
||||||
|
- [一键部署(Linux,推荐)](#一键部署linux推荐)
|
||||||
|
- [首次登录与初始化](#首次登录与初始化)
|
||||||
|
- [手动部署](#手动部署)
|
||||||
|
- [配置说明](#配置说明)
|
||||||
|
- [反向代理(HTTPS/WSS)](#反向代理httpswss)
|
||||||
|
- [服务管理](#服务管理)
|
||||||
|
- [升级](#升级)
|
||||||
|
- [目录说明](#目录说明)
|
||||||
|
- [常见问题排查](#常见问题排查)
|
||||||
|
- [相关文档](#相关文档)
|
||||||
|
|
||||||
1. 环境变量 `LMVPN_JWT_SECRET`
|
---
|
||||||
2. 配置文件 `data/config.yml` 中的 `web.jwt_secret`
|
|
||||||
3. 首次启动时自动生成 32 字节随机密钥并写入配置文件
|
|
||||||
|
|
||||||
生产环境建议通过环境变量注入,避免密钥落盘。
|
## 环境要求
|
||||||
|
|
||||||
### 默认管理员
|
- **操作系统**:Linux(已测试),需 root 或 sudo 权限
|
||||||
|
- **Go**:≥ 1.26.4
|
||||||
|
- **Node.js**:≥ 22.18(用于构建前端)
|
||||||
|
- **内核**:支持 TUN 设备(`/dev/net/tun`),开启 `ip_forward`
|
||||||
|
- **防火墙工具**:nftables(推荐)或 iptables
|
||||||
|
- **网络**:公网 IP,开放 Web 端口(或经反向代理)
|
||||||
|
|
||||||
首次启动时自动创建管理员账户,密码为随机生成的 16 位字符串。密码会:
|
---
|
||||||
|
|
||||||
|
## 一键部署(Linux,推荐)
|
||||||
|
|
||||||
|
仓库自带 `install_linux.sh`,一条命令完成构建、部署、网络配置与 systemd 服务安装。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <仓库地址> lmvpn_server
|
||||||
|
cd lmvpn_server
|
||||||
|
sudo bash install_linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 脚本执行流程
|
||||||
|
|
||||||
|
1. 拉取最新代码(`git fetch origin && git reset --hard origin/main`)
|
||||||
|
2. 构建前端:`cd frontend && npm install && npm run build`(产物输出到 `../dist`)
|
||||||
|
3. 编译后端:`go build -o lmvpn .`
|
||||||
|
4. 创建无 shell 的系统用户 `lmvpn`
|
||||||
|
5. 停止旧服务,部署到 `/opt/lmvpn`(二进制 + `dist/`),属主改为 `lmvpn`
|
||||||
|
6. 开启内核 IP 转发(IPv4 / IPv6),写入 `/etc/sysctl.d/99-lmvpn.conf`
|
||||||
|
7. 配置 NAT 与转发规则:自动检测出口网卡,优先 nft,回退 iptables
|
||||||
|
8. 安装 systemd 服务 `/etc/systemd/system/lmvpn.service`,以 `lmvpn` 用户运行并授予 `CAP_NET_ADMIN` / `CAP_NET_RAW`
|
||||||
|
9. 启动服务并打印状态
|
||||||
|
|
||||||
|
> ⚠️ 脚本中的 `git reset --hard origin/main` 会**丢弃所有本地未提交改动**。部署前请确保工作区干净,或先提交/暂存。
|
||||||
|
|
||||||
|
### 子网变量
|
||||||
|
|
||||||
|
脚本顶部有两个变量需与后台 VPN 设置保持一致:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VPN_SUBNET="192.168.77.0/24" # IPv4 子网
|
||||||
|
VPN_SUBNET6="fd00:dead:beef::/112" # IPv6 子网,留空则不配置 IPv6 NAT
|
||||||
|
```
|
||||||
|
|
||||||
|
若在管理后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 NAT 规则,否则客户端无法上网。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 首次登录与初始化
|
||||||
|
|
||||||
|
1. **获取初始密码**:首次启动时自动创建管理员 `admin`,密码为随机 16 位字符串,会
|
||||||
- 打印到 stdout(仅一次)
|
- 打印到 stdout(仅一次)
|
||||||
- 写入 `data/.initial_admin_password`(权限 0600)
|
- 写入 `/opt/lmvpn/data/.initial_admin_password`(权限 0600)
|
||||||
|
|
||||||
请登录后立即修改密码,并删除 `data/.initial_admin_password` 文件。
|
```bash
|
||||||
|
sudo cat /opt/lmvpn/data/.initial_admin_password
|
||||||
|
```
|
||||||
|
|
||||||
### 登录限流
|
2. **登录**:浏览器访问 `http://<服务器IP>:8080`,使用 `admin` + 初始密码登录。
|
||||||
|
|
||||||
`/api/login` 和 WebSocket 密码认证均限制每 IP 5 次/分钟。
|
3. **立即修改密码**,并删除初始密码文件:
|
||||||
|
|
||||||
### Unix Socket 权限
|
```bash
|
||||||
|
sudo rm /opt/lmvpn/data/.initial_admin_password
|
||||||
|
```
|
||||||
|
|
||||||
默认权限兼容反向代理(如 Caddy),可通过配置文件调整:
|
4. **启用 VPN**:进入「管理后台 → VPN 管理」,确认子网(默认 `192.168.77.0/24` + IPv6 `fd00:dead:beef::/112`),打开「启用」并保存。
|
||||||
|
|
||||||
|
5. **诊断检查**:VPN 管理页的「系统环境检测」面板(对应 `GET /api/admin/vpn/diag`)会检测 ip_forward、NAT、TUN 等,客户端无法上网时优先查看此处。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动部署
|
||||||
|
|
||||||
|
如需自行控制部署细节,可参照以下步骤。
|
||||||
|
|
||||||
|
### 1. 构建产物
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build # 产物输出到 ../dist
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
go build -o lmvpn .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 部署文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -r -s /bin/false lmvpn
|
||||||
|
sudo mkdir -p /opt/lmvpn
|
||||||
|
sudo cp lmvpn /opt/lmvpn/
|
||||||
|
sudo cp -r dist /opt/lmvpn/
|
||||||
|
sudo chown -R lmvpn:lmvpn /opt/lmvpn
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 开启内核转发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sysctl -w net.ipv4.ip_forward=1
|
||||||
|
sudo sysctl -w net.ipv6.conf.all.forwarding=1 # IPv6 双栈时需要
|
||||||
|
|
||||||
|
cat <<'EOF' | sudo tee /etc/sysctl.d/99-lmvpn.conf
|
||||||
|
net.ipv4.ip_forward = 1
|
||||||
|
net.ipv6.conf.all.forwarding = 1
|
||||||
|
EOF
|
||||||
|
sudo sysctl -p /etc/sysctl.d/99-lmvpn.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置 NAT(nft 示例)
|
||||||
|
|
||||||
|
将 `WAN_IFACE` 替换为出口网卡,`VPN_SUBNET` 替换为 VPN 子网:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WAN_IFACE=eth0
|
||||||
|
VPN_SUBNET=192.168.77.0/24
|
||||||
|
VPN_SUBNET6=fd00:dead:beef::/112
|
||||||
|
|
||||||
|
sudo nft add table inet lmvpn_nat
|
||||||
|
sudo nft 'add chain inet lmvpn_nat postrouting { type nat hook postrouting priority 100 ; }'
|
||||||
|
sudo nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip saddr "$VPN_SUBNET" masquerade
|
||||||
|
sudo nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip6 saddr "$VPN_SUBNET6" masquerade
|
||||||
|
sudo nft 'add chain inet lmvpn_nat forward { type filter hook forward priority 0 ; policy accept ; }'
|
||||||
|
sudo nft add rule inet lmvpn_nat forward ip saddr "$VPN_SUBNET" accept
|
||||||
|
sudo nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 安装 systemd 服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/systemd/system/lmvpn.service >/dev/null <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=LMVPN Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lmvpn
|
||||||
|
WorkingDirectory=/opt/lmvpn
|
||||||
|
ExecStart=/opt/lmvpn/lmvpn
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||||
|
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now lmvpn
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
配置文件位于 `data/config.yml`(相对工作目录,即 `/opt/lmvpn/data/config.yml`),首次启动时自动生成。
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
web:
|
web:
|
||||||
sock: "/run/lmvpnweb.sock"
|
port: 8080 # TCP 监听端口,0 则不监听 TCP
|
||||||
sock_mode: "0666" # socket 文件权限,默认 0666
|
sock: "/run/lmvpnweb.sock" # Unix Socket 路径,空则不监听 socket
|
||||||
sock_group: "" # socket 文件 group,空=不修改
|
sock_mode: "0666" # socket 文件权限
|
||||||
sock_dir_mode: "0755" # socket 目录权限,默认 0755
|
sock_group: "" # socket 文件属组,空=不修改
|
||||||
|
sock_dir_mode: "0755" # socket 目录权限
|
||||||
|
jwt_secret: "" # JWT 密钥,留空则按下面规则生成
|
||||||
|
database:
|
||||||
|
type: sqlite # sqlite 或 mysql
|
||||||
|
path: data/lmvpn.db # sqlite 文件路径
|
||||||
|
dsn: "" # mysql DSN(type=mysql 时必填)
|
||||||
```
|
```
|
||||||
|
|
||||||
多租户/高安全场景建议收紧:
|
> `web.port` 与 `web.sock` **至少配置一个**,两者都配则同时监听。生产环境建议仅留一个,由反向代理统一接入。
|
||||||
|
|
||||||
|
### JWT 密钥
|
||||||
|
|
||||||
|
按以下优先级加载:
|
||||||
|
|
||||||
|
1. 环境变量 `LMVPN_JWT_SECRET`
|
||||||
|
2. 配置文件 `web.jwt_secret`
|
||||||
|
3. 首次启动自动生成 32 字节随机密钥并写入配置文件
|
||||||
|
|
||||||
|
生产环境建议通过环境变量注入,避免密钥落盘:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/systemd/system/lmvpn.service 的 [Service] 段追加
|
||||||
|
Environment=LMVPN_JWT_SECRET=<你的密钥>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unix Socket 权限收紧
|
||||||
|
|
||||||
|
多租户/高安全场景建议收紧 socket 权限(将 lmvpn 进程用户加入反代用户组):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
web:
|
web:
|
||||||
sock: "/run/lmvpnweb.sock"
|
sock: "/run/lmvpnweb.sock"
|
||||||
sock_mode: "0660"
|
sock_mode: "0660"
|
||||||
sock_group: "caddy" # 将 lmvpn 进程用户加入 caddy group
|
sock_group: "caddy"
|
||||||
sock_dir_mode: "0750"
|
sock_dir_mode: "0750"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 生产部署
|
> 若服务以非 root 用户运行且 socket 目录不可写,可将 `sock` 指向用户可写目录(如 `/opt/lmvpn/run/lmvpnweb.sock`),或设 `sock: ""` 仅用 TCP。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反向代理(HTTPS/WSS)
|
||||||
|
|
||||||
|
生产环境**必须**通过反向代理提供 HTTPS/WSS。服务端默认监听 HTTP `:8080`,由反代终结 TLS。
|
||||||
|
|
||||||
|
### Caddy(自动 HTTPS)
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
lmvpn.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 的 `reverse_proxy` 自动处理 WebSocket 升级,无需额外配置。
|
||||||
|
|
||||||
|
若使用 Unix Socket 接入:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
lmvpn.example.com {
|
||||||
|
reverse_proxy unix//run/lmvpnweb.sock
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name lmvpn.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/lmvpn.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/lmvpn.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 部署反代后,客户端应使用 `wss://lmvpn.example.com/ws` 连接。`/ws` 的长连接超时建议调大(如 3600s),避免空闲断连。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start lmvpn # 启动
|
||||||
|
sudo systemctl stop lmvpn # 停止
|
||||||
|
sudo systemctl restart lmvpn # 重启
|
||||||
|
sudo systemctl status lmvpn # 状态
|
||||||
|
sudo systemctl enable lmvpn # 开机自启
|
||||||
|
```
|
||||||
|
|
||||||
|
查看实时日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u lmvpn -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
最简方式:重新执行一键脚本,它会自动拉取最新代码、重新构建并重启服务。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd lmvpn_server
|
||||||
|
git pull
|
||||||
|
sudo bash install_linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> 脚本会 `git reset --hard origin/main`,本地改动会被覆盖。`/opt/lmvpn/data/` 下的配置与数据库不受影响。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录说明
|
||||||
|
|
||||||
|
- `data/` — 运行时数据(`config.yml`、`lmvpn.db`、初始密码文件),gitignore
|
||||||
|
- `dist/` — 前端构建产物,由 Go 服务端托管,gitignore
|
||||||
|
- `frontend/` — 前端工程(Vue 3 + TypeScript + Vite)
|
||||||
|
- `internal/` — Go 后端源码
|
||||||
|
- `config/` — 配置加载
|
||||||
|
- `db/` — 数据库初始化
|
||||||
|
- `handler/` — HTTP 请求处理
|
||||||
|
- `middleware/` — 中间件(认证、限流)
|
||||||
|
- `model/` — 数据模型
|
||||||
|
- `router/` — 路由
|
||||||
|
- `vpn/` — VPN 核心(认证、隧道、TUN、包转发)
|
||||||
|
- `docs/` — 文档
|
||||||
|
- `pytest/` — Python 测试脚本
|
||||||
|
- `install_linux.sh` — Linux 一键部署脚本
|
||||||
|
- `main.go` — 服务端入口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
| 现象 | 可能原因 | 排查建议 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 服务启动失败 | socket 目录不可写 / 端口占用 | `journalctl -u lmvpn`;设 `sock: ""` 或换可写目录 |
|
||||||
|
| 客户端连上但无法上网 | 未开 ip_forward / 未配 NAT / 子网不一致 | 管理后台诊断面板;核对脚本 `VPN_SUBNET` 与后台设置 |
|
||||||
|
| 登录提示「请求过于频繁」 | 触发限流(`/api/login` 5 次/分钟·IP) | 等待 1 分钟后重试 |
|
||||||
|
| 修改子网后客户端异常 | NAT 规则仍是旧子网 | 同步脚本变量并重跑 `install_linux.sh` |
|
||||||
|
| WebSocket 频繁断开 | 反代未透传 `Upgrade`/`Connection` 头 | 检查反代 `/ws` 配置;调大读超时 |
|
||||||
|
| 忘记管理员密码 | — | 删除 `data/lmvpn.db` 重新初始化(会丢失所有数据),或直接改库 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [LMVPN 客户端项目](https://github.com/wuwenfengmi1998/lmvpn_client) — 配套客户端源码
|
||||||
|
- [客户端开发协议规范](docs/client-development.md) — WebSocket 隧道协议、认证、握手、数据面、TUN 配置等完整规范
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
- 生产环境必须通过反向代理(如 Caddy/Nginx)提供 HTTPS/WSS
|
|
||||||
- 配置文件 `data/config.yml` 权限为 0600,仅限运行用户读写
|
- 配置文件 `data/config.yml` 权限为 0600,仅限运行用户读写
|
||||||
- WebSocket `/ws` 端点支持 JWT 认证(`?token=xxx`)和密码认证两种方式
|
- `/api/login` 与 WebSocket 密码认证均限制每 IP 5 次/分钟
|
||||||
|
- WebSocket `/ws` 支持 JWT(`?token=xxx`)与用户名/密码两种认证
|
||||||
|
- 生产环境务必经反向代理启用 HTTPS/WSS
|
||||||
|
|||||||
+29
-1
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { toggleLocale } from '@/i18n'
|
import { toggleLocale } from '@/i18n'
|
||||||
import logo from '@/assets/logo.svg'
|
import logo from '@/assets/logo.svg'
|
||||||
@@ -9,6 +10,20 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const versionInfo = ref<{ commit: string; commitTime: string } | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/version')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
versionInfo.value = { commit: data.commit, commitTime: data.commit_time }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 版本信息获取失败时静默处理,footer 仅显示版权
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -16,7 +31,6 @@ function handleLogout() {
|
|||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/', label: 'nav.home' },
|
{ to: '/', label: 'nav.home' },
|
||||||
{ to: '/about', label: 'nav.about' },
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -39,6 +53,17 @@ const navLinks = [
|
|||||||
>
|
>
|
||||||
{{ t(link.label) }}
|
{{ t(link.label) }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<a
|
||||||
|
href="https://github.com/wuwenfengmi1998/lmvpn_server"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors hover:bg-sky-500/40"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path d="M12 .5C5.37.5 0 5.78 0 12.292c0 5.211 3.438 9.63 8.205 11.188.6.111.82-.254.82-.567 0-.28-.01-1.022-.015-2.005-3.338.711-4.042-1.582-4.042-1.582-.546-1.361-1.335-1.725-1.335-1.725-1.087-.731.084-.716.084-.716 1.205.082 1.838 1.215 1.838 1.215 1.07 1.803 2.809 1.282 3.495.981.108-.763.417-1.282.76-1.577-2.665-.295-5.466-1.309-5.466-5.827 0-1.287.465-2.339 1.235-3.164-.135-.298-.54-1.497.105-3.121 0 0 1.005-.316 3.3 1.209.96-.262 1.98-.392 3-.398 1.02.006 2.04.136 3 .398 2.28-1.525 3.285-1.209 3.285-1.209.645 1.624.24 2.823.12 3.121.765.825 1.23 1.877 1.23 3.164 0 4.53-2.805 5.527-5.475 5.817.42.354.81 1.077.81 2.182 0 1.578-.015 2.846-.015 3.229 0 .315.21.687.825.57C20.565 21.917 24 17.495 24 12.292 24 5.78 18.627.5 12 .5z" />
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
<template v-if="authStore.isLoggedIn">
|
<template v-if="authStore.isLoggedIn">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@@ -95,6 +120,9 @@ const navLinks = [
|
|||||||
<footer class="bg-slate-800 text-slate-400 py-6 text-center text-sm">
|
<footer class="bg-slate-800 text-slate-400 py-6 text-center text-sm">
|
||||||
<div class="max-w-6xl mx-auto px-4">
|
<div class="max-w-6xl mx-auto px-4">
|
||||||
<p>© {{ new Date().getFullYear() }} LmVPN. All rights reserved.</p>
|
<p>© {{ new Date().getFullYear() }} LmVPN. All rights reserved.</p>
|
||||||
|
<p v-if="versionInfo" class="mt-1 text-slate-500">
|
||||||
|
{{ t('footer.lastCommit') }}: {{ versionInfo.commitTime }} · {{ versionInfo.commit }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,17 +10,27 @@ const { t } = useI18n()
|
|||||||
<WelcomeItem>
|
<WelcomeItem>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
<template #heading>{{ t('home.secureTunnel') }}</template>
|
<template #heading>{{ t('home.tunnel') }}</template>
|
||||||
{{ t('home.secureTunnelDesc') }}
|
{{ t('home.tunnelDesc') }}
|
||||||
</WelcomeItem>
|
</WelcomeItem>
|
||||||
|
|
||||||
<WelcomeItem>
|
<WelcomeItem>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template #heading>{{ t('home.userManage') }}</template>
|
||||||
|
{{ t('home.userManageDesc') }}
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
<template #heading>{{ t('home.multiDevice') }}</template>
|
<template #heading>{{ t('home.multiDevice') }}</template>
|
||||||
@@ -30,22 +40,51 @@ const { t } = useI18n()
|
|||||||
<WelcomeItem>
|
<WelcomeItem>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM3.6 9h16.8M3.6 15h16.8M11.5 3a17 17 0 000 18M12.5 3a17 17 0 010 18" />
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
<template #heading>{{ t('home.trafficMonitor') }}</template>
|
<template #heading>{{ t('home.dualStack') }}</template>
|
||||||
{{ t('home.trafficMonitorDesc') }}
|
{{ t('home.dualStackDesc') }}
|
||||||
</WelcomeItem>
|
</WelcomeItem>
|
||||||
|
|
||||||
<WelcomeItem>
|
<WelcomeItem>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0zM15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
<template #heading>{{ t('home.easyConfig') }}</template>
|
<template #heading>{{ t('home.reservation') }}</template>
|
||||||
{{ t('home.easyConfigDesc') }}
|
{{ t('home.reservationDesc') }}
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template #heading>{{ t('home.traffic') }}</template>
|
||||||
|
{{ t('home.trafficDesc') }}
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template #heading>{{ t('home.session') }}</template>
|
||||||
|
{{ t('home.sessionDesc') }}
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template #heading>{{ t('home.deploy') }}</template>
|
||||||
|
{{ t('home.deployDesc') }}
|
||||||
</WelcomeItem>
|
</WelcomeItem>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+29
-13
@@ -62,7 +62,7 @@ export default {
|
|||||||
about: {
|
about: {
|
||||||
title: 'About LmVPN',
|
title: 'About LmVPN',
|
||||||
description:
|
description:
|
||||||
'LmVPN is a lightweight VPN tunnel management system built with Go + Vue 3. It supports multiple VPN protocols and provides secure and reliable point-to-point and point-to-site networking solutions.',
|
'LmVPN is a lightweight Layer-3 VPN system built on WebSocket tunnels and TUN virtual NICs using Go + Vue 3. It supports multi-user management, IPv4/IPv6 dual-stack, static IP reservations, and traffic statistics for point-to-point and point-to-site networking.',
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: 'Admin Dashboard',
|
title: 'Admin Dashboard',
|
||||||
@@ -149,19 +149,35 @@ export default {
|
|||||||
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?',
|
||||||
},
|
},
|
||||||
|
footer: {
|
||||||
|
lastCommit: 'Last commit',
|
||||||
|
},
|
||||||
home: {
|
home: {
|
||||||
tagline: 'Secure, fast, and reliable VPN tunnel management system',
|
tagline:
|
||||||
secureTunnel: 'Secure Tunnel',
|
'A lightweight Layer-3 VPN tunnel management system built on WebSocket and TUN virtual NICs',
|
||||||
secureTunnelDesc:
|
tunnel: 'WebSocket Tunnel',
|
||||||
'Based on WireGuard/OpenVPN protocols with end-to-end encryption to keep your data safe.',
|
tunnelDesc:
|
||||||
multiDevice: 'Multi-Device Support',
|
'Layer-3 tunnel over WebSocket and TUN virtual NICs, with WSS/TLS transport security via reverse proxy.',
|
||||||
|
userManage: 'Multi-User Management',
|
||||||
|
userManageDesc:
|
||||||
|
'Admin and user roles with full user CRUD, enable/disable, and password changes, plus self-protection rules.',
|
||||||
|
multiDevice: 'Multi-Device Concurrent',
|
||||||
multiDeviceDesc:
|
multiDeviceDesc:
|
||||||
'Connect multiple devices simultaneously — phones, computers, and routers all supported.',
|
'Up to 3 concurrent connections per user with automatic tunnel IP assignment.',
|
||||||
trafficMonitor: 'Traffic Monitoring',
|
dualStack: 'IPv4/IPv6 Dual-Stack',
|
||||||
trafficMonitorDesc:
|
dualStackDesc:
|
||||||
'Real-time traffic statistics and bandwidth analysis to stay on top of network usage.',
|
'Supports both IPv4 and IPv6 subnets with NAT dual-stack forwarding and anti-spoofing.',
|
||||||
easyConfig: 'Easy Configuration',
|
reservation: 'Static IP Reservation',
|
||||||
easyConfigDesc:
|
reservationDesc:
|
||||||
'One-click deployment with auto-configuration — no advanced networking knowledge required.',
|
'Reserve fixed IPv4/IPv6 addresses per user with automatic subnet and uniqueness validation.',
|
||||||
|
traffic: 'Traffic Statistics',
|
||||||
|
trafficDesc:
|
||||||
|
'Real-time online device and traffic statistics with daily aggregated records.',
|
||||||
|
session: 'Session Management',
|
||||||
|
sessionDesc:
|
||||||
|
'JWT token sessions with self-revocation and admin force-logout, with cascading invalidation.',
|
||||||
|
deploy: 'One-Click Deploy & Diagnostics',
|
||||||
|
deployDesc:
|
||||||
|
'One-click Linux deployment script with a built-in system environment diagnostics panel.',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-11
@@ -62,7 +62,7 @@ export default {
|
|||||||
about: {
|
about: {
|
||||||
title: '关于 LmVPN',
|
title: '关于 LmVPN',
|
||||||
description:
|
description:
|
||||||
'LmVPN 是一款轻量级 VPN 隧道管理系统,采用 Go + Vue 3 技术栈构建。支持多种 VPN 协议,提供安全可靠的点对点与点对站点网络连接方案。',
|
'LmVPN 是一款基于 WebSocket 隧道与 TUN 虚拟网卡的轻量级三层 VPN 系统,采用 Go + Vue 3 构建。支持多用户管理、IPv4/IPv6 双栈、静态 IP 预留与流量统计,提供点对点与点对站点网络连接方案。',
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: '管理后台',
|
title: '管理后台',
|
||||||
@@ -148,16 +148,30 @@ export default {
|
|||||||
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
|
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
|
||||||
confirmDeleteReservation: '确认删除该预留?',
|
confirmDeleteReservation: '确认删除该预留?',
|
||||||
},
|
},
|
||||||
|
footer: {
|
||||||
|
lastCommit: '最后提交',
|
||||||
|
},
|
||||||
home: {
|
home: {
|
||||||
tagline: '安全、快速、可靠的 VPN 隧道管理系统',
|
tagline: '基于 WebSocket 与 TUN 虚拟网卡的轻量级三层 VPN 隧道管理系统',
|
||||||
secureTunnel: '安全隧道',
|
tunnel: 'WebSocket 隧道',
|
||||||
secureTunnelDesc:
|
tunnelDesc:
|
||||||
'基于 WireGuard/OpenVPN 协议,端到端加密传输,保障数据安全无泄漏。',
|
'基于 WebSocket 与 TUN 虚拟网卡的三层隧道,经反向代理提供 WSS/TLS 传输安全。',
|
||||||
multiDevice: '多设备支持',
|
userManage: '多用户管理',
|
||||||
multiDeviceDesc: '同时连接多台设备,手机、电脑、路由器全平台覆盖。',
|
userManageDesc:
|
||||||
trafficMonitor: '流量监控',
|
'管理员与普通角色,支持用户增删改、启用禁用与改密,内置自保护规则。',
|
||||||
trafficMonitorDesc: '实时流量统计与带宽分析,随时掌握网络使用状况。',
|
multiDevice: '多设备并发',
|
||||||
easyConfig: '简易配置',
|
multiDeviceDesc: '每用户最多 3 个并发连接,自动分配隧道 IP 地址。',
|
||||||
easyConfigDesc: '一键部署、自动配置,无需复杂网络知识即可上线。',
|
dualStack: 'IPv4/IPv6 双栈',
|
||||||
|
dualStackDesc: '同时支持 IPv4 与 IPv6 子网,NAT 双栈转发与源地址反欺骗。',
|
||||||
|
reservation: '静态 IP 预留',
|
||||||
|
reservationDesc:
|
||||||
|
'按用户预留固定 IPv4/IPv6 地址,自动校验子网合法性与唯一性。',
|
||||||
|
traffic: '流量统计',
|
||||||
|
trafficDesc: '实时统计在线设备与流量,按日聚合记录网络使用状况。',
|
||||||
|
session: '会话管理',
|
||||||
|
sessionDesc:
|
||||||
|
'JWT 令牌会话,支持自行注销与管理员强制下线,状态变更级联失效。',
|
||||||
|
deploy: '一键部署与诊断',
|
||||||
|
deployDesc: 'Linux 一键脚本部署,内置系统环境检测面板快速定位问题。',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,14 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// 已登录但用户信息未加载(如刷新页面后),先获取用户信息
|
||||||
|
if (authStore.isLoggedIn && !authStore.user) {
|
||||||
|
await authStore.fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||||
next({ name: 'login' })
|
next({ name: 'login' })
|
||||||
} else if (to.meta.adminOnly && authStore.user?.role !== 'admin') {
|
} else if (to.meta.adminOnly && authStore.user?.role !== 'admin') {
|
||||||
|
|||||||
+12
-4
@@ -14,9 +14,13 @@ if [ "$(id -u)" -ne 0 ]; then
|
|||||||
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
|
||||||
@@ -25,7 +29,11 @@ npm run build
|
|||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
echo ">>> 编译 Go 后端..."
|
echo ">>> 编译 Go 后端..."
|
||||||
go build -o lmvpn .
|
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
COMMIT_TIME=$(git log -1 --format=%cI)
|
||||||
|
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
go build -ldflags "-X lmvpn/internal/version.Version=$VERSION -X lmvpn/internal/version.Commit=$COMMIT -X lmvpn/internal/version.CommitTime=$COMMIT_TIME -X lmvpn/internal/version.BuildTime=$BUILD_TIME" -o lmvpn .
|
||||||
|
|
||||||
echo ">>> 创建 lmvpn 系统用户..."
|
echo ">>> 创建 lmvpn 系统用户..."
|
||||||
if ! id lmvpn &>/dev/null; then
|
if ! id lmvpn &>/dev/null; then
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"lmvpn/internal/version"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type versionResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
CommitTime string `json:"commit_time"`
|
||||||
|
BuildTime string `json:"build_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVersion(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, versionResponse{
|
||||||
|
Version: version.Version,
|
||||||
|
Commit: version.Commit,
|
||||||
|
CommitTime: version.CommitTime,
|
||||||
|
BuildTime: version.BuildTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ func Setup(r *gin.Engine) {
|
|||||||
r.GET("/ws", vpn.HandleWS)
|
r.GET("/ws", vpn.HandleWS)
|
||||||
|
|
||||||
r.POST("/api/login", middleware.LoginRateLimit(), handler.Login)
|
r.POST("/api/login", middleware.LoginRateLimit(), handler.Login)
|
||||||
|
r.GET("/api/version", handler.GetVersion)
|
||||||
|
|
||||||
auth := r.Group("/api")
|
auth := r.Group("/api")
|
||||||
auth.Use(middleware.AuthMiddleware())
|
auth.Use(middleware.AuthMiddleware())
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = "unknown"
|
||||||
|
CommitTime = "unknown"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
)
|
||||||
@@ -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
-7
@@ -155,10 +155,13 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go s.serveTUN()
|
go s.serveTUN()
|
||||||
log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +245,7 @@ func (s *VpnService) Stop() error {
|
|||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("VPN 服务已停止")
|
log.Printf("[VPN] stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-2
@@ -1,6 +1,7 @@
|
|||||||
package vpn
|
package vpn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -106,6 +107,16 @@ func parseIPAddrs(packet []byte) (src, dest net.IP, ok bool) {
|
|||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func protoName(packet []byte) string {
|
||||||
|
if waterutil.IsIPv4(packet) {
|
||||||
|
return "IPv4"
|
||||||
|
}
|
||||||
|
if waterutil.IsIPv6(packet) {
|
||||||
|
return "IPv6"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PacketSwitch) allowC2C() bool {
|
func (s *PacketSwitch) allowC2C() bool {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
v := s.allowClientToClient
|
v := s.allowClientToClient
|
||||||
@@ -116,43 +127,67 @@ func (s *PacketSwitch) allowC2C() bool {
|
|||||||
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 {
|
||||||
|
log.Printf("[ROUTE] client->? parse failed size=%d", len(packet))
|
||||||
return nil
|
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()) {
|
||||||
|
log.Printf("[ROUTE] anti-spoof drop src=%s expected=%s dst=%s proto=%s size=%d",
|
||||||
|
srcIP, src.AssignedIP(), dest, proto, len(packet))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assigned6 := src.AssignedIP6()
|
assigned6 := src.AssignedIP6()
|
||||||
if assigned6 == nil || !srcIP.Equal(assigned6) {
|
if assigned6 == nil || !srcIP.Equal(assigned6) {
|
||||||
|
log.Printf("[ROUTE] anti-spoof drop src=%s expected=%s dst=%s proto=%s size=%d",
|
||||||
|
srcIP, assigned6, dest, proto, len(packet))
|
||||||
return nil
|
return 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
|
||||||
}
|
}
|
||||||
|
log.Printf("[ROUTE] client->drop(broadcast,c2c off) src=%s dst=%s proto=%s size=%d",
|
||||||
|
src.AssignedIP(), dest, proto, len(packet))
|
||||||
return nil
|
return 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-8
@@ -41,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,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)
|
||||||
@@ -170,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
|
||||||
@@ -184,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,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
|
||||||
}
|
}
|
||||||
@@ -207,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)
|
||||||
@@ -215,11 +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 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user