feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
This commit is contained in:
1 parent
58c036c34c
commit
1e2f8ef24f
43 files changed
+6783
-460
No files matched your search
@@ -6,54 +6,149 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
|
||||
|
||||
| 协议 | 实现方式 | 场景 |
|
||||
|---|---|---|
|
||||
| WireGuard | 系统 `wg` / `wg-quick`,密钥自动生成 | 内部接入、移动办公(首选) |
|
||||
| IPSec | StrongSwan `swanctl`,内置 Cisco/飞塔/华为/华三模板 | **第三方对接**、站点互联 |
|
||||
| L2TP/IPSec | StrongSwan + xl2tpd | 手机/老设备兼容 |
|
||||
| FRP | 直接管理 `frps` / `frpc`(TOML) | 内网穿透、端口映射 |
|
||||
| OpenVPN | 进程管理 + 配置模板 + PKI 脚本 | 老项目兼容 |
|
||||
| WireGuard | 系统 `wg` / `wg-quick`,多 Peer、NAT/MTU、流量累计 | 内部接入、移动办公(首选) |
|
||||
| IPSec | StrongSwan `swanctl`,厂商模板一键套用 | **第三方对接**、站点互联 |
|
||||
| IKEv2 远程接入 | StrongSwan Road Warrior,PSK + EAP-MSCHAPv2 | 员工手机/笔记本原生拨号 |
|
||||
| L2TP/IPSec | StrongSwan + xl2tpd,支持多拨号用户 | 手机/老设备兼容 |
|
||||
| FRP | 直接管理 `frps` / `frpc`(TOML),含 STCP/XTCP | 内网穿透、端口映射 |
|
||||
| OpenVPN | 进程管理 + PKI;UDP/TCP 可切换;证书/密码 + TOTP MFA | 老项目兼容 |
|
||||
| 自定义协议 | 插件命令钩子(Create/Start/Stop/Status/Config) | 任意协议扩展 |
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **专线列表**:名称/协议/状态/流量/到期时间,一键启停、编辑、删除、下载配置、WireGuard 二维码
|
||||
- **创建向导**:选协议 → 填参数 → 自动生成服务端与客户端配置
|
||||
- **IPSec 第三方对接专页**:设备模板预置 Phase1/Phase2 参数、双方参数一致性检查、一键连通性测试、对端参考配置导出
|
||||
- **节点管理**:多中继节点(本机内置,远程节点预留)
|
||||
- **协议插件**:注册命令钩子即接入新协议,钩子通过 stdin 接收专线配置 JSON
|
||||
- **创建向导**:选协议 → 依赖检测门控(未就绪不可选,引导下载/安装)→ 填参数(高级折叠)→ 必填校验
|
||||
- **在线会话**:跨协议统一展示在线用户、公网 IP、VPN IP、连接时长、流量;支持踢下线
|
||||
- **流量审计**:连接审计日志(connect/disconnect/auth_fail)、异常突发流量告警、管理员手动踢下线
|
||||
- **流量配额**:隧道级配额(GB)、超额动作(告警/停止/限速)、周期重置(日/月)
|
||||
- **协议引擎管理**:FRP/OpenVPN 支持平台一键下载部署;WireGuard/StrongSwan 显示系统包安装命令
|
||||
- **WireGuard Peer 管理**:详情内增删 Peer、按设备下载配置/二维码
|
||||
- **拨号用户**:OpenVPN / L2TP / IKEv2 共用用户表;支持从平台用户导入、批量创建、LDAP 浏览导入
|
||||
- **IPSec 第三方对接专页**:设备模板预置 Phase1/Phase2 参数、双方参数一致性检查、一键连通性测试
|
||||
- **节点管理**:多中继节点,远程节点支持 SSH 凭证验证(密码/私钥、即时测试连接)
|
||||
- **专线日志**:操作栏「更多 → 查看日志」SSE 实时输出实例进程日志
|
||||
- **安全**:PSK/私钥 AES-GCM 加密存储、配置文件 0600 权限、操作审计
|
||||
- **访问控制**:admin / operator / viewer;首登强制改密;登录防爆破;TOTP MFA + 备用码;LDAP
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 单二进制构建与运行(默认,不使用 Docker)
|
||||
### 本地构建与运行
|
||||
|
||||
```bash
|
||||
# 方式一:一键构建并运行
|
||||
./run.sh run
|
||||
|
||||
# 方式二:make
|
||||
make run
|
||||
./run.sh run # 构建并运行(默认 admin / admin123)
|
||||
./run.sh dev # 开发模式(前后端分离)
|
||||
./run.sh restart # 重新构建并重启
|
||||
./run.sh stop # 停止服务
|
||||
```
|
||||
|
||||
`run.sh` 会自动完成「构建前端 → 编译 Go → 启动单二进制」全流程,监听 `:8080`,默认账号 **admin / admin123**。
|
||||
|
||||
### 分步构建
|
||||
### Linux 服务器部署
|
||||
|
||||
```bash
|
||||
./run.sh build # 全量构建(前端 + 二进制)
|
||||
./run.sh build-frontend # 仅构建前端
|
||||
./run.sh build-binary # 仅编译二进制(需先构建前端)
|
||||
./opsx-vpn # 直接运行二进制
|
||||
# 1. 交叉编译 Linux 二进制(在 macOS 上)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o opsx-vpn ./backend
|
||||
|
||||
# 2. 上传到服务器
|
||||
scp opsx-vpn user@server:/opt/opsx-vpn/
|
||||
cd /opt/opsx-vpn && chmod +x opsx-vpn
|
||||
|
||||
# 3. 安装系统依赖(按需)
|
||||
apt install -y wireguard-tools openvpn strongswan strongswan-swanctl iptables
|
||||
# FRP 无需系统包 —— 平台 UI 内点「下载部署」自动从 GitHub 下载到 data/bin/
|
||||
|
||||
# 4. 启动(生产建议用 systemd 管理)
|
||||
./opsx-vpn # 监听 :8080,数据目录 ./data
|
||||
```
|
||||
|
||||
### 开发模式(前后端分离)
|
||||
### systemd 服务(生产推荐)
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/opsx-vpn.service
|
||||
[Unit]
|
||||
Description=VPN Management Platform
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/opsx-vpn
|
||||
ExecStart=/opt/opsx-vpn/opsx-vpn
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# 环境变量按需调整
|
||||
Environment=VPN_LISTEN_ADDR=:8080
|
||||
Environment=VPN_DATA_DIR=/opt/opsx-vpn/data
|
||||
# WireGuard/OpenVPN 需要 root 权限(创建 TUN 设备 + iptables)
|
||||
# 如需降权,需配置 NET_ADMIN capability
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
./run.sh dev
|
||||
# 前端 http://localhost:5173(vite dev,代理 /api 到后端)
|
||||
# 后端 http://localhost:8080(go run)
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now opsx-vpn
|
||||
journalctl -u opsx-vpn -f # 查看日志
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
## 防火墙 / 安全组端口
|
||||
|
||||
部署到云服务器时,需开放以下端口:
|
||||
|
||||
### VPN 数据通道(对公网开放)
|
||||
|
||||
| 端口 | 协议 | 类型 | 用途 |
|
||||
|---|---|---|---|
|
||||
| UDP 51820 | WireGuard | UDP | WG 默认监听端口(可在创建时改) |
|
||||
| UDP 1194 | OpenVPN | UDP | OpenVPN 默认端口(可改 TCP) |
|
||||
| TCP 1194 | OpenVPN | TCP | OpenVPN TCP 模式(如选 TCP) |
|
||||
| UDP 500 | IPSec / IKEv2 / L2TP | UDP | IKE 密钥交换 |
|
||||
| UDP 4500 | IPSec / IKEv2 / L2TP | UDP | NAT-T 穿透 |
|
||||
| UDP 1701 | L2TP | UDP | L2TP 隧道 |
|
||||
| TCP 7000 | FRP | TCP | frps 绑定端口(客户端连接用) |
|
||||
|
||||
### 管理(仅限你的 IP 或内网)
|
||||
|
||||
| 端口 | 协议 | 类型 | 用途 |
|
||||
|---|---|---|---|
|
||||
| TCP 8080 | 管理平台 | TCP | Web UI + API(**勿对公网开放**,建议 Nginx HTTPS 后改 443) |
|
||||
| TCP 22 | SSH | TCP | 远程管理 |
|
||||
|
||||
### 安全组配置示例
|
||||
|
||||
```
|
||||
# VPN 数据通道(对公网开放)
|
||||
UDP 500/4500 0.0.0.0/0 # IPSec/IKEv2/L2TP 密钥交换 + NAT-T
|
||||
UDP 51820 0.0.0.0/0 # WireGuard
|
||||
UDP 1194 0.0.0.0/0 # OpenVPN UDP
|
||||
TCP 1194 0.0.0.0/0 # OpenVPN TCP(如启用)
|
||||
UDP 1701 0.0.0.0/0 # L2TP
|
||||
TCP 7000 0.0.0.0/0 # FRP server
|
||||
|
||||
# 管理(仅限你的 IP)
|
||||
TCP 8080 你的IP/32 # 管理 UI
|
||||
TCP 22 你的IP/32 # SSH
|
||||
```
|
||||
|
||||
> **注意**:IPSec 必须同时开放 UDP 500 + 4500(缺一个连不上)。管理端口 8080 切勿对公网开放。仅用 WireGuard 最简部署只需 3 个端口:UDP 51820 + TCP 8080 + TCP 22。
|
||||
|
||||
## 协议依赖安装
|
||||
|
||||
| 协议 | 安装方式 | 平台下载 |
|
||||
|---|---|---|
|
||||
| WireGuard | `apt install -y wireguard-tools` | ❌ 系统包 |
|
||||
| OpenVPN | `apt install -y openvpn` | ✅ GitHub Release / 系统包 |
|
||||
| StrongSwan (IPSec/IKEv2/L2TP) | `apt install -y strongswan strongswan-swanctl` | ❌ 系统包 |
|
||||
| FRP | 无需系统包 | ✅ 平台 UI「下载部署」自动从 GitHub 下载 |
|
||||
|
||||
在「总览 → 协议引擎状态」表中,未就绪的协议会显示「下载部署」或「安装命令」按钮。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `VPN_LISTEN_ADDR` | `:8080` | 监听地址 |
|
||||
| `VPN_DATA_DIR` | `./data` | 数据目录(SQLite、密钥、实例配置、下载的二进制) |
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
@@ -61,16 +156,9 @@ make run
|
||||
| `./run.sh build` | 全量构建(前端 + 后端二进制) |
|
||||
| `./run.sh dev` | 开发模式(前后端分离) |
|
||||
| `./run.sh stop` | 停止所有服务 |
|
||||
| `./run.sh restart` | 重启服务 |
|
||||
| `./run.sh restart` | 重新构建并重启 |
|
||||
| `./run.sh status` | 查看服务状态 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `VPN_LISTEN_ADDR` | `:8080` | 监听地址 |
|
||||
| `VPN_DATA_DIR` | `./data` | 数据目录(SQLite、密钥、实例配置) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -83,28 +171,17 @@ backend/
|
||||
model/ # GORM 数据模型
|
||||
store/ # SQLite + AES-GCM 密钥存储
|
||||
service/ # 协议实现
|
||||
wireguard/ ipsec/ l2tp/ frp/ openvpn/
|
||||
wireguard/ ipsec/ ikev2/ l2tp/ frp/ openvpn/
|
||||
custom/ # 自定义协议插件
|
||||
monitor/ # 状态/流量采集、到期自动停止
|
||||
monitor/ # 状态/流量/会话/配额采集引擎
|
||||
sessionmgr/ # 在线会话同步 + 连接审计
|
||||
trafficmgr/ # 流量配额 + 异常检测 + 限速
|
||||
binmgr/ # 协议二进制下载部署 + 安装引导
|
||||
sshutil/ # SSH 连接测试(节点验证)
|
||||
```
|
||||
|
||||
## 自定义协议插件契约
|
||||
|
||||
在「协议插件」页注册 5 个命令钩子,平台执行时将 `{"id","name","protocol","config"}` JSON 写入 stdin:
|
||||
|
||||
| 钩子 | 约定 |
|
||||
|---|---|
|
||||
| Start(必填) | 启动实例,失败退出码非 0 |
|
||||
| Stop | 停止实例 |
|
||||
| Status | stdout 输出 `running` / `stopped` |
|
||||
| Config | stdout 输出客户端配置文本(供下载) |
|
||||
| Create(可选) | 创建时调用,用于渲染配置 |
|
||||
|
||||
## 依赖检测与部署状态
|
||||
|
||||
平台启动时自动检测 `wg / swanctl / xl2tpd / openvpn / frps / frpc`。依赖缺失时仍可创建和导出配置,但专线会标记为**未部署**,启动操作将明确报错;安装对应依赖后即可由平台启动和管理真实进程。
|
||||
|
||||
## 交付形态
|
||||
|
||||
- **单二进制**:前端构建后通过 `go:embed` 内嵌进 Go 二进制,`./opsx-vpn` 单文件即可运行(含 SPA + REST API + SQLite)
|
||||
- 不依赖 Docker、不依赖外部数据库
|
||||
- 纯静态编译:`CGO_ENABLED=0` 可交叉编译到任意 Linux/Windows
|
||||
+739
-58
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -12,10 +15,14 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service/ikev2"
|
||||
"vpn-platform/backend/internal/service/l2tp"
|
||||
"vpn-platform/backend/internal/service/openvpn"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -80,12 +87,26 @@ func ldapSettings() (*model.LDAPConfig, error) {
|
||||
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
||||
url := cfg.URL
|
||||
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
|
||||
url = "ldap://" + url
|
||||
// 安全默认:未显式指定协议时按 ldaps,避免口令明文过网
|
||||
url = "ldaps://" + url
|
||||
}
|
||||
conn, err := ldap.DialURL(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.StartTLS && strings.HasPrefix(url, "ldap://") {
|
||||
host := strings.TrimPrefix(url, "ldap://")
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
|
||||
}
|
||||
} else if strings.HasPrefix(url, "ldap://") {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("明文 ldap:// 存在口令泄露风险,请改用 ldaps:// 或开启 StartTLS")
|
||||
}
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
||||
conn.Close()
|
||||
@@ -130,16 +151,48 @@ func verifyMFA(user *model.User, code string) error {
|
||||
if err := store.DB.Where("user_id = ? AND enabled = ?", user.ID, true).First(&device).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return fmt.Errorf("需要有效的 MFA 验证码")
|
||||
}
|
||||
secret, err := store.DecryptSecret(device.Secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MFA 配置无效")
|
||||
}
|
||||
if totp.Validate(strings.TrimSpace(code), secret) {
|
||||
if totp.Validate(code, secret) {
|
||||
return nil
|
||||
}
|
||||
// 备用码一次性消费(丢失验证器时恢复)
|
||||
if consumeAdminBackupCode(&device, code) {
|
||||
store.Audit(user.Username, "mfa_backup_used", user.Username, "使用备用码登录")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("需要有效的 MFA 验证码")
|
||||
}
|
||||
|
||||
func consumeAdminBackupCode(device *model.MFADevice, code string) bool {
|
||||
plain, err := store.DecryptSecret(device.BackupCodes)
|
||||
if err != nil || plain == "" {
|
||||
return false
|
||||
}
|
||||
var codes []string
|
||||
if json.Unmarshal([]byte(plain), &codes) != nil {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), " ", ""))
|
||||
for i, bc := range codes {
|
||||
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(bc)), []byte(normalized)) == 1 {
|
||||
codes = append(codes[:i], codes[i+1:]...)
|
||||
raw, _ := json.Marshal(codes)
|
||||
if enc, err := store.EncryptSecret(string(raw)); err == nil {
|
||||
store.DB.Model(device).Update("backup_codes", enc)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) mfaStatus(c *gin.Context) {
|
||||
var device model.MFADevice
|
||||
err := store.DB.Where("user_id = ?", c.GetUint("user_id")).First(&device).Error
|
||||
@@ -268,6 +321,7 @@ func (s *Server) updateUser(c *gin.Context) {
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
user.MustChangePassword = true // 管理员重置后强制改密
|
||||
}
|
||||
store.DB.Save(&user)
|
||||
c.JSON(200, user)
|
||||
@@ -300,6 +354,7 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
|
||||
BaseDN string `json:"base_dn"`
|
||||
UserFilter string `json:"user_filter"`
|
||||
UsernameAttribute string `json:"username_attribute"`
|
||||
StartTLS bool `json:"starttls"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || (req.Enabled && (req.URL == "" || req.BaseDN == "")) {
|
||||
@@ -308,7 +363,8 @@ func (s *Server) saveLDAPConfig(c *gin.Context) {
|
||||
}
|
||||
var cfg model.LDAPConfig
|
||||
store.DB.First(&cfg)
|
||||
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.Enabled = req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.Enabled
|
||||
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.StartTLS, cfg.Enabled =
|
||||
req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.StartTLS, req.Enabled
|
||||
if req.BindPassword != "" {
|
||||
cfg.BindPassword, _ = store.EncryptSecret(req.BindPassword)
|
||||
}
|
||||
@@ -361,17 +417,32 @@ func (s *Server) createVPNUser(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(req.TunnelID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "隧道不存在"})
|
||||
return
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "openvpn", "l2tp", "ikev2":
|
||||
default:
|
||||
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
|
||||
return
|
||||
}
|
||||
user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true}
|
||||
if req.Enabled != nil {
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if user.Source == "local" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(400, gin.H{"error": "本地拨号用户密码必填"})
|
||||
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
// L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。
|
||||
if enc, err := store.EncryptSecret(req.Password); err == nil {
|
||||
user.DialSecret = enc
|
||||
}
|
||||
} else if user.LDAPDN == "" {
|
||||
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
|
||||
return
|
||||
@@ -380,6 +451,7 @@ func (s *Server) createVPNUser(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
_ = refreshDialAuth(t)
|
||||
c.JSON(200, user)
|
||||
}
|
||||
func (s *Server) updateVPNUser(c *gin.Context) {
|
||||
@@ -399,17 +471,470 @@ func (s *Server) updateVPNUser(c *gin.Context) {
|
||||
}
|
||||
user.DisplayName, user.Email = req.DisplayName, req.Email
|
||||
if req.Enabled != nil {
|
||||
if !*req.Enabled && user.Enabled {
|
||||
revokeOpenVPNCert(&user)
|
||||
}
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Password != "" {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
if enc, err := store.EncryptSecret(req.Password); err == nil {
|
||||
user.DialSecret = enc
|
||||
}
|
||||
}
|
||||
store.DB.Save(&user)
|
||||
if t, err := store.GetTunnel(user.TunnelID); err == nil {
|
||||
_ = refreshDialAuth(t)
|
||||
}
|
||||
c.JSON(200, user)
|
||||
}
|
||||
func (s *Server) deleteVPNUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error == nil {
|
||||
revokeOpenVPNCert(&user)
|
||||
tunnelID := user.TunnelID
|
||||
store.DB.Delete(&model.VPNUser{}, id)
|
||||
if t, err := store.GetTunnel(tunnelID); err == nil {
|
||||
_ = refreshDialAuth(t)
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
store.DB.Delete(&model.VPNUser{}, id)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func revokeOpenVPNCert(user *model.VPNUser) {
|
||||
if user.Certificate == "" {
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(user.TunnelID)
|
||||
if err != nil || t.Protocol != "openvpn" {
|
||||
return
|
||||
}
|
||||
if err := openvpn.New().RevokeClientCertificate(t, user); err != nil {
|
||||
store.Audit("system", "revoke_cert_failed", user.Username, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
|
||||
func (s *Server) vpnUserMFASetup(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(user.TunnelID)
|
||||
if err != nil || t.Protocol != "openvpn" {
|
||||
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
|
||||
return
|
||||
}
|
||||
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "OPSX-OpenVPN",
|
||||
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pendingMFAMu.Lock()
|
||||
// 用 user_id 偏移避免与管理端 MFA 冲突:拨号用户用高位 key 空间
|
||||
pendingMFAs[vpnUserPendingKey(user.ID)] = pendingMFA{
|
||||
secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute),
|
||||
}
|
||||
pendingMFAMu.Unlock()
|
||||
c.JSON(200, gin.H{
|
||||
"otpauth_url": key.URL(),
|
||||
"secret": key.Secret(),
|
||||
"period": 30,
|
||||
"digits": 6,
|
||||
"algorithm": "SHA1",
|
||||
"encoding": "base32",
|
||||
"expires_in": 900,
|
||||
"hint": "兼容主流 Authenticator 应用:扫描二维码或手动输入 base32 密钥",
|
||||
})
|
||||
}
|
||||
|
||||
func vpnUserPendingKey(vpnUserID uint) uint {
|
||||
// 管理端 MFA 用真实 user_id;拨号 MFA pending 用高位偏移隔离。
|
||||
return 1_000_000_000 + vpnUserID
|
||||
}
|
||||
|
||||
func (s *Server) vpnUserMFAEnable(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.Code == "" {
|
||||
c.JSON(400, gin.H{"error": "请输入验证码"})
|
||||
return
|
||||
}
|
||||
pendingMFAMu.Lock()
|
||||
pending, ok := pendingMFAs[vpnUserPendingKey(user.ID)]
|
||||
delete(pendingMFAs, vpnUserPendingKey(user.ID))
|
||||
pendingMFAMu.Unlock()
|
||||
if !ok || time.Now().After(pending.expires) || !totp.Validate(strings.TrimSpace(req.Code), pending.secret) {
|
||||
c.JSON(400, gin.H{"error": "验证码无效或设置已过期,请重新扫描二维码"})
|
||||
return
|
||||
}
|
||||
secret, err := store.EncryptSecret(pending.secret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "加密 MFA 密钥失败"})
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(pending.backups)
|
||||
backups, err := store.EncryptSecret(string(raw))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "加密备用码失败"})
|
||||
return
|
||||
}
|
||||
user.MFASecret = secret
|
||||
user.MFABackupCodes = backups
|
||||
user.MFAEnabled = true
|
||||
if err := store.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "enable_vpn_user_mfa", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups, "mfa_enabled": true})
|
||||
}
|
||||
|
||||
func (s *Server) vpnUserMFADisable(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
store.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"mfa_enabled": false, "mfa_secret": "", "mfa_backup_codes": "",
|
||||
"mfa_pin": "", "mfa_has_pin": false, "mfa_fail_count": 0, "mfa_locked_until": nil,
|
||||
"mfa_last_counter": 0, "mfa_last_used_at": nil,
|
||||
})
|
||||
store.Audit(c.GetString("username"), "disable_vpn_user_mfa", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "mfa_enabled": false})
|
||||
}
|
||||
|
||||
// vpnUserMFAStatus OTP Manager:查看拨号用户 MFA 状态。
|
||||
func (s *Server) vpnUserMFAStatus(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
locked := user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil)
|
||||
c.JSON(200, gin.H{
|
||||
"mfa_enabled": user.MFAEnabled,
|
||||
"mfa_has_pin": user.MFAHasPin,
|
||||
"mfa_fail_count": user.MFAFailCount,
|
||||
"mfa_locked": locked,
|
||||
"mfa_locked_until": user.MFALockedUntil,
|
||||
"mfa_last_used_at": user.MFALastUsedAt,
|
||||
"compatible": "TOTP 标准(兼容 Google/Microsoft Authenticator,sha1:base32:6:30s)",
|
||||
})
|
||||
}
|
||||
|
||||
// vpnUserMFAPin 设置或清除静态 PIN(连接时第二因子 = PIN+OTP)。
|
||||
func (s *Server) vpnUserMFAPin(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
c.JSON(400, gin.H{"error": "请先启用 MFA 再设置 PIN"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Pin string `json:"pin"` // 空字符串表示清除
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "set_vpn_user_mfa_pin", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "mfa_has_pin": user.MFAHasPin})
|
||||
}
|
||||
|
||||
// vpnUserMFABackupCodes 重新生成备用码。
|
||||
func (s *Server) vpnUserMFABackupCodes(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
codes, err := openvpn.RegenerateBackupCodes(&user)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "regen_vpn_user_mfa_backup", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true, "backup_codes": codes})
|
||||
}
|
||||
|
||||
// vpnUserMFAUnlock 清除 MFA 锁定(失败次数过多后)。
|
||||
func (s *Server) vpnUserMFAUnlock(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := openvpn.UnlockUserMFA(&user); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "unlock_vpn_user_mfa", user.Username, "")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// vpnUserOTPSecretsLine 导出 TOTP 密钥行(标准 otp-secrets 格式,便于迁移与备份)。
|
||||
func (s *Server) vpnUserOTPSecretsLine(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
line, err := openvpn.GenerateOTPSecretsLine(&user)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"line": line,
|
||||
"format": "user server type:hash:encoding:key:pin:udid client",
|
||||
"note": "标准 otp-secrets 格式密钥行;密钥敏感,请妥善保管,切勿泄露",
|
||||
})
|
||||
}
|
||||
|
||||
// refreshDialAuth 在拨号用户变更后重写 L2TP/IKEv2 认证文件。
|
||||
func refreshDialAuth(t *model.Tunnel) error {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
switch t.Protocol {
|
||||
case "l2tp":
|
||||
return l2tp.New().RefreshAuth(t)
|
||||
case "ikev2":
|
||||
return ikev2.New().RefreshAuth(t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 用户批量导入 / LDAP 导入 ----
|
||||
|
||||
// listLDAPUsers 浏览 LDAP 目录中的用户列表,供导入选择。复用 ldapSettings + ldapConnect。
|
||||
func (s *Server) listLDAPUsers(c *gin.Context) {
|
||||
cfg, err := ldapSettings()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "LDAP 未启用或配置不完整:" + err.Error()})
|
||||
return
|
||||
}
|
||||
conn, err := ldapConnect(cfg)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 搜索 BaseDN 下所有匹配用户过滤器的条目
|
||||
attrs := []string{"dn", cfg.UsernameAttribute, "cn", "mail"}
|
||||
result, err := conn.Search(ldap.NewSearchRequest(
|
||||
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
cfg.UserFilter, attrs, nil,
|
||||
))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "LDAP 搜索失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询本地已有的 LDAP 用户名,标记已导入
|
||||
var existingNames []string
|
||||
store.DB.Model(&model.User{}).Where("source = ?", "ldap").Pluck("username", &existingNames)
|
||||
existingMap := map[string]bool{}
|
||||
for _, n := range existingNames {
|
||||
existingMap[n] = true
|
||||
}
|
||||
|
||||
type ldapUser struct {
|
||||
DN string `json:"dn"`
|
||||
UID string `json:"uid"`
|
||||
CN string `json:"cn"`
|
||||
Mail string `json:"mail"`
|
||||
Imported bool `json:"imported"`
|
||||
}
|
||||
users := make([]ldapUser, 0, len(result.Entries))
|
||||
for _, entry := range result.Entries {
|
||||
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
users = append(users, ldapUser{
|
||||
DN: entry.DN,
|
||||
UID: uid,
|
||||
CN: entry.GetAttributeValue("cn"),
|
||||
Mail: entry.GetAttributeValue("mail"),
|
||||
Imported: existingMap[uid],
|
||||
})
|
||||
}
|
||||
c.JSON(200, users)
|
||||
}
|
||||
|
||||
// batchCreateUsers 批量创建本地用户。
|
||||
func (s *Server) batchCreateUsers(c *gin.Context) {
|
||||
var req struct {
|
||||
Users []struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
} `json:"users"`
|
||||
DefaultRole string `json:"default_role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.DefaultRole == "" {
|
||||
req.DefaultRole = "viewer"
|
||||
}
|
||||
type failInfo struct {
|
||||
Username string `json:"username"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
var success, skipped, failed int
|
||||
var fails []failInfo
|
||||
for _, u := range req.Users {
|
||||
if u.Username == "" {
|
||||
failed++
|
||||
fails = append(fails, failInfo{Error: "用户名为空"})
|
||||
continue
|
||||
}
|
||||
// 检查是否已存在
|
||||
var count int64
|
||||
store.DB.Model(&model.User{}).Where("username = ?", u.Username).Count(&count)
|
||||
if count > 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
pwd := u.Password
|
||||
if len(pwd) < 8 {
|
||||
failed++
|
||||
fails = append(fails, failInfo{Username: u.Username, Error: "密码至少 8 位"})
|
||||
continue
|
||||
}
|
||||
role := u.Role
|
||||
if role == "" {
|
||||
role = req.DefaultRole
|
||||
}
|
||||
if role != "admin" && role != "operator" && role != "viewer" {
|
||||
role = "viewer"
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||
user := model.User{
|
||||
Username: u.Username, Password: string(hash), Email: u.Email,
|
||||
Role: role, Source: "local", Enabled: true,
|
||||
}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
failed++
|
||||
fails = append(fails, failInfo{Username: u.Username, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
store.Audit(c.GetString("username"), "batch_create_user", u.Username, "")
|
||||
success++
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": success, "skipped": skipped, "failed": failed,
|
||||
"errors": fails, "total": len(req.Users),
|
||||
})
|
||||
}
|
||||
|
||||
// importLDAPUsers 从 LDAP 导入选中的用户到本地 DB(source=ldap)。
|
||||
func (s *Server) importLDAPUsers(c *gin.Context) {
|
||||
var req struct {
|
||||
DNs []string `json:"dns"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
cfg, err := ldapSettings()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "LDAP 未启用:" + err.Error()})
|
||||
return
|
||||
}
|
||||
conn, err := ldapConnect(cfg)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
type failInfo struct {
|
||||
DN string `json:"dn"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
var success, skipped, failed int
|
||||
var fails []failInfo
|
||||
for _, dn := range req.DNs {
|
||||
// 按 DN 查询单个条目
|
||||
result, err := conn.Search(ldap.NewSearchRequest(
|
||||
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(objectClass=*)", []string{cfg.UsernameAttribute, "cn", "mail"}, nil,
|
||||
))
|
||||
if err != nil || len(result.Entries) == 0 {
|
||||
failed++
|
||||
fails = append(fails, failInfo{DN: dn, Error: "查询失败"})
|
||||
continue
|
||||
}
|
||||
entry := result.Entries[0]
|
||||
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
|
||||
if uid == "" {
|
||||
failed++
|
||||
fails = append(fails, failInfo{DN: dn, Error: "无 uid 属性"})
|
||||
continue
|
||||
}
|
||||
// 检查本地是否已存在
|
||||
var count int64
|
||||
store.DB.Model(&model.User{}).Where("username = ?", uid).Count(&count)
|
||||
if count > 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
user := model.User{
|
||||
Username: uid, Email: entry.GetAttributeValue("mail"),
|
||||
Role: "viewer", Source: "ldap", Enabled: true,
|
||||
}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
failed++
|
||||
fails = append(fails, failInfo{DN: dn, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
store.Audit(c.GetString("username"), "import_ldap_user", uid, dn)
|
||||
success++
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": success, "skipped": skipped, "failed": failed,
|
||||
"errors": fails, "total": len(req.DNs),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// Package binmgr 提供协议二进制的下载部署与安装引导能力。
|
||||
// 参考 opsx-proxy 的 xray_install.go 模式:
|
||||
// - FRP: 从 GitHub Releases 下载预编译二进制到 data/bin/
|
||||
// - WireGuard/OpenVPN/StrongSwan: 检测系统包管理器,返回安装命令
|
||||
package binmgr
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BinDir 二进制存放目录(data/bin/)
|
||||
func BinDir() string {
|
||||
return filepath.Join(dataDir(), "bin")
|
||||
}
|
||||
|
||||
// dataDir 获取数据目录(复用 service.BaseDir 逻辑)
|
||||
func dataDir() string {
|
||||
if v := os.Getenv("VPN_DATA_DIR"); v != "" {
|
||||
return v
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
return filepath.Join(filepath.Dir(exe), "data")
|
||||
}
|
||||
return "data"
|
||||
}
|
||||
|
||||
// ---- GitHub Release 下载(FRP / OpenVPN 通用)----
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
// deploySpec 定义可平台下载部署的协议规格
|
||||
type deploySpec struct {
|
||||
Repo string // GitHub 仓库(owner/repo)
|
||||
BinNames []string // 要提取的二进制名(如 frps/frpc)
|
||||
AssetHint string // 匹配 asset 的关键词(用于从 release assets 中选对)
|
||||
WindowsExt string // Windows 下的二进制扩展名
|
||||
}
|
||||
|
||||
// 可下载部署的协议列表
|
||||
var deploySpecs = map[string]deploySpec{
|
||||
"frp": {
|
||||
Repo: "fatedier/frp",
|
||||
BinNames: []string{"frps", "frpc"},
|
||||
AssetHint: "", // 匹配逻辑:tag + goos + goarch
|
||||
},
|
||||
"openvpn": {
|
||||
Repo: "OpenVPN/openvpn",
|
||||
BinNames: []string{"openvpn"},
|
||||
AssetHint: "", // openvpn release 匹配逻辑略不同,见下方
|
||||
},
|
||||
}
|
||||
|
||||
// DownloadBinary 通用下载部署:从 GitHub Releases 下载协议二进制到 data/bin/
|
||||
func DownloadBinary(name string) (string, error) {
|
||||
spec, ok := deploySpecs[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("协议 %s 不支持平台下载部署", name)
|
||||
}
|
||||
|
||||
// 1. 获取最新 release
|
||||
release, err := fetchLatestRelease(spec.Repo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 %s 版本失败: %w", name, err)
|
||||
}
|
||||
|
||||
// 2. 匹配当前平台的 asset
|
||||
assetURL, assetFile := matchAsset(name, spec, release)
|
||||
if assetURL == "" {
|
||||
return "", fmt.Errorf("未找到匹配 %s/%s 的 %s 发布包", runtime.GOOS, runtime.GOARCH, name)
|
||||
}
|
||||
|
||||
// 3. 下载 + 解压 + 提取
|
||||
if err := os.MkdirAll(BinDir(), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpDir, err := os.MkdirTemp("", name+"-dl-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
archivePath := filepath.Join(tmpDir, assetFile)
|
||||
if err := downloadFile(assetURL, archivePath); err != nil {
|
||||
return "", fmt.Errorf("下载失败: %w", err)
|
||||
}
|
||||
|
||||
extractDir := filepath.Join(tmpDir, "extracted")
|
||||
if strings.HasSuffix(assetFile, ".zip") {
|
||||
err = unzipTo(archivePath, extractDir)
|
||||
} else if strings.HasSuffix(assetFile, ".tar.gz") || strings.HasSuffix(assetFile, ".tgz") {
|
||||
err = untargzTo(archivePath, extractDir)
|
||||
} else {
|
||||
// 单文件直接复制
|
||||
err = os.MkdirAll(extractDir, 0o755)
|
||||
if err == nil {
|
||||
err = copyFile(archivePath, filepath.Join(extractDir, assetFile))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 提取二进制到 data/bin/
|
||||
version := release.TagName
|
||||
for _, binName := range spec.BinNames {
|
||||
// 尝试原名 + .exe
|
||||
candidates := []string{binName}
|
||||
if runtime.GOOS == "windows" {
|
||||
candidates = []string{binName + ".exe", binName}
|
||||
}
|
||||
for _, cand := range candidates {
|
||||
src := findFile(extractDir, cand)
|
||||
if src != "" {
|
||||
dstName := binName
|
||||
if runtime.GOOS == "windows" {
|
||||
dstName += ".exe"
|
||||
}
|
||||
dst := filepath.Join(BinDir(), dstName)
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return "", fmt.Errorf("复制 %s 失败: %w", binName, err)
|
||||
}
|
||||
os.Chmod(dst, 0o755)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 写版本标记
|
||||
os.WriteFile(filepath.Join(BinDir(), name+".version"), []byte(version), 0o644)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// matchAsset 根据协议名和当前平台从 release assets 中匹配下载 URL
|
||||
func matchAsset(name string, spec deploySpec, release *githubRelease) (url, file string) {
|
||||
goos := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
// GOARCH 别名
|
||||
if arch == "amd64" {
|
||||
arch = "amd64"
|
||||
}
|
||||
// 构建匹配关键词
|
||||
var keywords []string
|
||||
switch name {
|
||||
case "frp":
|
||||
// frp 发布包格式:frp_x.x.x_linux_amd64.tar.gz
|
||||
keywords = []string{goos + "_" + arch, goos + "-" + arch}
|
||||
case "openvpn":
|
||||
// OpenVPN 官方 release 主要是源码 tarball,预编译二进制在社区构建
|
||||
// 匹配 openvpn-windows-installers 或社区构建的 linux 包
|
||||
if goos == "windows" {
|
||||
keywords = []string{"windows", "win", ".exe", ".msi"}
|
||||
} else {
|
||||
// Linux 预编译:尝试匹配社区构建或退回源码包
|
||||
keywords = []string{goos, arch}
|
||||
}
|
||||
}
|
||||
// 遍历 assets 找最佳匹配
|
||||
for _, a := range release.Assets {
|
||||
lower := strings.ToLower(a.Name)
|
||||
// 排除明显不匹配的(如 .asc 签名、.src 源码)
|
||||
if strings.HasSuffix(lower, ".asc") || strings.HasSuffix(lower, ".sig") {
|
||||
continue
|
||||
}
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return a.BrowserDownloadURL, a.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// ---- 包管理器检测与安装引导 ----
|
||||
|
||||
// DetectPackageManager 检测系统包管理器
|
||||
func DetectPackageManager() string {
|
||||
for _, pm := range []string{"apt-get", "yum", "dnf", "apk", "pacman"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallHint 返回协议在各包管理器下的安装命令。
|
||||
// 同时返回当前系统检测到的包管理器,供前端高亮推荐。
|
||||
func InstallHint(protocol string) map[string]interface{} {
|
||||
detectedPM := DetectPackageManager()
|
||||
|
||||
// 嵌套 map: 协议 → 包管理器 → 安装命令
|
||||
hints := map[string]map[string]string{
|
||||
"wireguard": {
|
||||
"apt-get": "apt-get install -y wireguard-tools",
|
||||
"yum": "yum install -y epel-release && yum install -y wireguard-tools",
|
||||
"dnf": "dnf install -y epel-release && dnf install -y wireguard-tools",
|
||||
"apk": "apk add wireguard-tools",
|
||||
"pacman": "pacman -S --noconfirm wireguard-tools",
|
||||
},
|
||||
"openvpn": {
|
||||
"apt-get": "apt-get install -y openvpn",
|
||||
"yum": "yum install -y epel-release && yum install -y openvpn",
|
||||
"dnf": "dnf install -y epel-release && dnf install -y openvpn",
|
||||
"apk": "apk add openvpn",
|
||||
"pacman": "pacman -S --noconfirm openvpn",
|
||||
},
|
||||
"strongswan": {
|
||||
"apt-get": "apt-get install -y strongswan strongswan-swanctl",
|
||||
"yum": "yum install -y epel-release && yum install -y strongswan",
|
||||
"dnf": "dnf install -y epel-release && dnf install -y strongswan",
|
||||
"apk": "apk add strongswan",
|
||||
"pacman": "pacman -S --noconfirm strongswan",
|
||||
},
|
||||
}
|
||||
// IPSec / IKEv2 / L2TP 都依赖 strongswan
|
||||
displayName := protocol
|
||||
aliasMap := map[string]string{
|
||||
"ipsec": "strongswan", "ikev2": "strongswan", "l2tp": "strongswan",
|
||||
}
|
||||
if real, ok := aliasMap[protocol]; ok {
|
||||
protocol = real
|
||||
}
|
||||
protoHints, ok := hints[protocol]
|
||||
if !ok {
|
||||
return map[string]interface{}{"command": "", "pm": detectedPM, "all": map[string]string{}, "detected": false}
|
||||
}
|
||||
|
||||
// 当前系统推荐的命令
|
||||
recommendedCmd := protoHints[detectedPM]
|
||||
if recommendedCmd == "" {
|
||||
recommendedCmd = protoHints["apt-get"] // 无包管理器时兜底 apt
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"command": recommendedCmd, // 当前系统推荐的命令
|
||||
"pm": detectedPM, // 检测到的包管理器(空=未检测到)
|
||||
"detected": detectedPM != "",
|
||||
"protocol": displayName,
|
||||
"all_commands": protoHints, // 所有平台的命令,供前端展示
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 辅助函数 ----
|
||||
|
||||
// BinaryStatus 二进制安装状态
|
||||
type BinaryStatus struct {
|
||||
Name string `json:"name"`
|
||||
Installed bool `json:"installed"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"` // managed / system / undeployed
|
||||
Version string `json:"version"`
|
||||
CanDeploy bool `json:"can_deploy"` // 是否支持平台下载(仅 frp)
|
||||
InstallCmd string `json:"install_cmd"` // 包管理器安装命令(非 frp)
|
||||
}
|
||||
|
||||
// protocolBins 各协议需要检测的二进制名
|
||||
var protocolBins = map[string][]string{
|
||||
"frp": {"frps", "frpc"},
|
||||
"wireguard": {"wg", "wg-quick"},
|
||||
"openvpn": {"openvpn"},
|
||||
"strongswan": {"swanctl", "ipsec"},
|
||||
}
|
||||
|
||||
// CheckBinary 检查单个协议的二进制状态
|
||||
func CheckBinary(name string) BinaryStatus {
|
||||
st := BinaryStatus{Name: name}
|
||||
_, st.CanDeploy = deploySpecs[name] // frp/openvpn 支持平台下载
|
||||
|
||||
bins := protocolBins[name]
|
||||
if len(bins) == 0 {
|
||||
return st
|
||||
}
|
||||
|
||||
// 1. 检查 data/bin/(平台下载部署的)
|
||||
for _, binName := range bins {
|
||||
managed := filepath.Join(BinDir(), binName)
|
||||
if _, err := os.Stat(managed); err == nil {
|
||||
st.Installed = true
|
||||
st.Path = managed
|
||||
st.Source = "managed"
|
||||
if v, err := os.ReadFile(filepath.Join(BinDir(), name+".version")); err == nil {
|
||||
st.Version = strings.TrimSpace(string(v))
|
||||
}
|
||||
return st
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查系统 PATH
|
||||
for _, binName := range bins {
|
||||
if p, err := exec.LookPath(binName); err == nil {
|
||||
st.Installed = true
|
||||
st.Path = p
|
||||
st.Source = "system"
|
||||
return st
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 未安装:返回安装命令(供系统包协议用)
|
||||
hint := InstallHint(name)
|
||||
if cmd, ok := hint["command"].(string); ok {
|
||||
st.InstallCmd = cmd
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// CheckAll 检查所有协议二进制状态
|
||||
func CheckAll() []BinaryStatus {
|
||||
return []BinaryStatus{
|
||||
CheckBinary("frp"),
|
||||
CheckBinary("wireguard"),
|
||||
CheckBinary("openvpn"),
|
||||
CheckBinary("strongswan"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 下载与解压工具 ----
|
||||
|
||||
func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "opsx-vpn")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("GitHub API 返回 %d", resp.StatusCode)
|
||||
}
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("下载返回 %d", resp.StatusCode)
|
||||
}
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func unzipTo(zipPath, dest string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range r.File {
|
||||
path := filepath.Join(dest, f.Name)
|
||||
if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
continue // 防路径穿越
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, 0o755)
|
||||
continue
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func untargzTo(targzPath, dest string) error {
|
||||
f, err := os.Open(targzPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dest, hdr.Name)
|
||||
if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(path, 0o755)
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
io.Copy(out, tr)
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findFile(dir, name string) string {
|
||||
var found string
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() && info.Name() == name {
|
||||
found = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
@@ -5,15 +5,16 @@ import "time"
|
||||
|
||||
// User 平台账号
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||
Email string `gorm:"size:128" json:"email"`
|
||||
Password string `gorm:"size:128" json:"-"` // 仅本地用户保存 bcrypt 哈希
|
||||
Role string `gorm:"size:16;default:viewer" json:"role"` // admin / operator / viewer
|
||||
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||
Email string `gorm:"size:128" json:"email"`
|
||||
Password string `gorm:"size:128" json:"-"` // 仅本地用户保存 bcrypt 哈希
|
||||
Role string `gorm:"size:16;default:viewer" json:"role"` // admin / operator / viewer
|
||||
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
MustChangePassword bool `gorm:"default:false" json:"must_change_password"` // 首登/被重置后强制改密
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LDAPConfig LDAP 身份源配置。BindPassword 由 store.EncryptSecret 加密后保存。
|
||||
@@ -25,6 +26,7 @@ type LDAPConfig struct {
|
||||
BaseDN string `gorm:"size:256" json:"base_dn"`
|
||||
UserFilter string `gorm:"size:512" json:"user_filter"`
|
||||
UsernameAttribute string `gorm:"size:64" json:"username_attribute"`
|
||||
StartTLS bool `gorm:"default:false" json:"starttls"` // ldap:// 时升级为 StartTLS
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -42,22 +44,35 @@ type MFADevice struct {
|
||||
}
|
||||
|
||||
// VPNUser 是拨号身份,不等同于管理平台登录账号;可来自本地或 LDAP。
|
||||
// Password 保存 bcrypt(OpenVPN 校验);DialSecret 保存 AES-GCM 可逆密文,
|
||||
// 供 L2TP/IKEv2 等需要明文 CHAP/EAP 的协议重新生成认证文件。
|
||||
// MFASecret / MFABackupCodes 为 OpenVPN 拨号 TOTP(AES-GCM 加密)。
|
||||
type VPNUser struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128;uniqueIndex:idx_vpn_user" json:"username"`
|
||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||
Email string `gorm:"size:128" json:"email"`
|
||||
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
|
||||
LDAPDN string `gorm:"size:512" json:"ldap_dn"`
|
||||
Password string `gorm:"size:128" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Certificate string `gorm:"size:128" json:"certificate"`
|
||||
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
|
||||
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index;uniqueIndex:idx_vpn_user" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128;uniqueIndex:idx_vpn_user" json:"username"`
|
||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||
Email string `gorm:"size:128" json:"email"`
|
||||
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
|
||||
LDAPDN string `gorm:"size:512" json:"ldap_dn"`
|
||||
Password string `gorm:"size:128" json:"-"`
|
||||
DialSecret string `gorm:"type:text" json:"-"` // AES-GCM,L2TP/IKEv2 拨号用
|
||||
MFASecret string `gorm:"type:text" json:"-"` // AES-GCM TOTP secret(base32,兼容主流 Authenticator 应用)
|
||||
MFABackupCodes string `gorm:"type:text" json:"-"` // AES-GCM JSON 数组
|
||||
MFAPin string `gorm:"type:text" json:"-"` // AES-GCM,可选静态 PIN(连接时第二因子 PIN+OTP)
|
||||
MFAEnabled bool `gorm:"default:false" json:"mfa_enabled"`
|
||||
MFAHasPin bool `gorm:"default:false" json:"mfa_has_pin"` // 是否设置了 PIN(不回显明文)
|
||||
MFAFailCount int `gorm:"default:0" json:"mfa_fail_count"`
|
||||
MFALockedUntil *time.Time `json:"mfa_locked_until"`
|
||||
MFALastUsedAt *time.Time `json:"mfa_last_used_at"`
|
||||
MFALastCounter int64 `gorm:"default:0" json:"-"` // 上次成功 TOTP 时间窗,防重放
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Certificate string `gorm:"size:128" json:"certificate"`
|
||||
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
|
||||
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tunnel 专线实例(所有协议统一存储,协议参数存 Config JSON)
|
||||
@@ -80,13 +95,19 @@ type Tunnel struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Node 中继节点(本机为内置节点)
|
||||
// Node 中继节点(本机为内置节点),remote 节点支持 SSH 凭证验证 // Logan.Li
|
||||
type Node struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128" json:"name"`
|
||||
Address string `gorm:"size:256" json:"address"` // 节点地址(API 或 SSH)
|
||||
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
|
||||
Address string `gorm:"size:256" json:"address"` // 节点地址(IP 或域名)
|
||||
Port int `gorm:"default:22" json:"port"` // SSH 端口
|
||||
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
|
||||
Username string `gorm:"size:64" json:"username"` // SSH 登录用户
|
||||
AuthType string `gorm:"size:16;default:password" json:"auth_type"` // password / key
|
||||
Secrets string `gorm:"type:text" json:"-"` // AES-GCM 加密的 {password, private_key} JSON
|
||||
Status string `gorm:"size:16;default:unknown" json:"status"` // verified / failed / unknown
|
||||
Remark string `gorm:"size:512" json:"remark"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -115,3 +136,42 @@ type AuditLog struct {
|
||||
Detail string `gorm:"size:1024" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// VPNSession 在线会话(跨协议统一记录拨号用户的连接状态) // Logan.Li
|
||||
type VPNSession struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||
VPNUserID uint `gorm:"index" json:"vpn_user_id"`
|
||||
Username string `gorm:"size:128" json:"username"`
|
||||
Protocol string `gorm:"size:32" json:"protocol"`
|
||||
RealAddress string `gorm:"size:64" json:"real_address"` // 客户端公网 IP:Port
|
||||
VirtualAddress string `gorm:"size:64" json:"virtual_address"` // 分配的 VPN IP
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
DisconnectedAt *time.Time `json:"disconnected_at"`
|
||||
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
|
||||
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
|
||||
Status string `gorm:"size:16;default:active" json:"status"` // active / disconnected
|
||||
}
|
||||
|
||||
// ConnectionLog 连接审计日志(连接/断开/认证失败/配额超额/异常流量) // Logan.Li
|
||||
type ConnectionLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128" json:"username"`
|
||||
Event string `gorm:"size:32" json:"event"` // connect / disconnect / auth_fail / quota_exceed / rate_alert
|
||||
IPAddress string `gorm:"size:64" json:"ip_address"`
|
||||
Detail string `gorm:"size:1024" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TrafficPolicy 流量配额与限速策略(挂在 Tunnel 上) // Logan.Li
|
||||
type TrafficPolicy struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"uniqueIndex" json:"tunnel_id"`
|
||||
QuotaBytes int64 `gorm:"default:0" json:"quota_bytes"` // 总配额,0=不限
|
||||
RateLimitMbps int `gorm:"default:0" json:"rate_limit_mbps"` // 限速 Mbps,0=不限
|
||||
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
||||
ResetCycle string `gorm:"size:16;default:none" json:"reset_cycle"` // daily / monthly / none
|
||||
LastResetAt time.Time `json:"last_reset_at"`
|
||||
ActionOnExceed string `gorm:"size:16;default:block" json:"action_on_exceed"` // warn / block / throttle
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package monitor 周期性采集各专线运行状态与流量
|
||||
// Package monitor 周期性采集各专线运行状态、流量、会话与配额 // Logan.Li
|
||||
package monitor
|
||||
|
||||
import (
|
||||
@@ -6,24 +6,28 @@ import (
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/sessionmgr"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/trafficmgr"
|
||||
)
|
||||
|
||||
const collectInterval = 15 * time.Second
|
||||
|
||||
// Start 启动状态采集循环(每 15 秒刷新一次)
|
||||
func Start(mgr *core.Manager, interval time.Duration) {
|
||||
if interval == 0 {
|
||||
interval = 15 * time.Second
|
||||
interval = collectInterval
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
collect(mgr) // 启动即采集一次
|
||||
collect(mgr, interval) // 启动即采集一次
|
||||
for range ticker.C {
|
||||
collect(mgr)
|
||||
collect(mgr, interval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func collect(mgr *core.Manager) {
|
||||
func collect(mgr *core.Manager, interval time.Duration) {
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Find(&tunnels).Error; err != nil {
|
||||
return
|
||||
@@ -39,18 +43,62 @@ func collect(mgr *core.Manager) {
|
||||
continue
|
||||
}
|
||||
mgr.SetStatus(t.ID, st)
|
||||
// 数据库状态与协议真实状态同步,避免页面长期展示过时的“运行中”。
|
||||
|
||||
// 数据库状态同步
|
||||
if st.State != "" && st.State != t.State {
|
||||
store.DB.Model(t).Update("state", st.State)
|
||||
}
|
||||
// 流量持久化(仅在有增量时写库)
|
||||
|
||||
// 流量增量计算(用于配额/会话/异常检测)
|
||||
deltaRx := st.RxBytes - t.RxBytes
|
||||
deltaTx := st.TxBytes - t.TxBytes
|
||||
if deltaRx < 0 {
|
||||
deltaRx = st.RxBytes
|
||||
}
|
||||
if deltaTx < 0 {
|
||||
deltaTx = st.TxBytes
|
||||
}
|
||||
|
||||
// 流量持久化(隧道级)
|
||||
if st.RxBytes > 0 || st.TxBytes > 0 {
|
||||
store.DB.Model(t).Updates(map[string]interface{}{"rx_bytes": st.RxBytes, "tx_bytes": st.TxBytes})
|
||||
}
|
||||
// 到期自动停止
|
||||
|
||||
// ---- 会话同步(阶段 1)----
|
||||
if st.Running {
|
||||
// WireGuard 等多对端协议:从 Peers 同步
|
||||
if len(st.Peers) > 0 {
|
||||
sessionmgr.SyncFromPeers(t.ID, t.Protocol, st.Peers)
|
||||
}
|
||||
// OpenVPN 的会话同步在 collectTraffic 内部完成(已直接写 VPNUser)
|
||||
} else {
|
||||
// 隧道未运行 → 标记所有 active 会话为 disconnected
|
||||
sessionmgr.SyncSessions(t.ID, t.Protocol, nil)
|
||||
}
|
||||
|
||||
// ---- 流量配额检查(阶段 2)----
|
||||
if deltaRx > 0 || deltaTx > 0 {
|
||||
action := trafficmgr.CheckQuota(t.ID, deltaRx, deltaTx)
|
||||
if action != "" {
|
||||
trafficmgr.HandleExceed(t.ID, action, t.Name)
|
||||
if action == "block" {
|
||||
_ = p.Stop(t)
|
||||
store.DB.Model(t).Update("state", "stopped")
|
||||
store.Audit("system", "quota_block", t.Name, "流量配额超额,自动停止")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 异常流量告警(阶段 3)----
|
||||
if st.Running && (deltaRx > 0 || deltaTx > 0) {
|
||||
trafficmgr.CheckRateAbnormal(t.ID, deltaRx, deltaTx, interval)
|
||||
}
|
||||
|
||||
// ---- 到期自动停止 ----
|
||||
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) && t.State == "running" {
|
||||
_ = p.Stop(t)
|
||||
store.DB.Model(t).Update("state", "stopped")
|
||||
store.Audit("system", "expire_stop", t.Name, "专线到期自动停止")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,22 +164,42 @@ func ReadPid(t *model.Tunnel) (int, bool) {
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// KillPid 停止实例进程
|
||||
// KillPid 优雅停止实例进程:先 SIGTERM,超时后再 SIGKILL。
|
||||
func KillPid(t *model.Tunnel) error {
|
||||
pid, alive := ReadPid(t)
|
||||
if !alive {
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireTCPPortAvailable 启动前检查 TCP 端口是否空闲。
|
||||
func RequireTCPPortAvailable(port int, component string) error {
|
||||
ln, err := net.Listen("tcp4", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法启动 %s:TCP %d 已被其他服务占用: %w", component, port, err)
|
||||
}
|
||||
return ln.Close()
|
||||
}
|
||||
|
||||
// WriteConfig 写入实例配置文件(0600 权限)
|
||||
func WriteConfig(t *model.Tunnel, name, content string) (string, error) {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -14,6 +16,17 @@ import (
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// findBin 查找 FRP 二进制路径:优先 data/bin/(平台下载),其次 PATH(系统安装)
|
||||
func findBin(name string) (string, bool) {
|
||||
// 1. data/bin/(平台下载部署的)
|
||||
managed := filepath.Join(service.BaseDir(), "bin", name)
|
||||
if fi, err := os.Stat(managed); err == nil && !fi.IsDir() {
|
||||
return managed, true
|
||||
}
|
||||
// 2. 系统 PATH
|
||||
return service.LookBin(name)
|
||||
}
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// Proxy is the supported, repeatable FRP proxy rule. It is deliberately kept
|
||||
@@ -29,6 +42,7 @@ type Proxy struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
Locations []string `json:"locations"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite"`
|
||||
SecretKey string `json:"secret_key,omitempty"` // stcp/xtcp 访问密钥
|
||||
}
|
||||
|
||||
var safeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
@@ -38,10 +52,13 @@ func (s *Service) Name() string { return "frp" }
|
||||
func (s *Service) DisplayName() string { return "FRP 内网穿透" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("frps", "frpc"); ok {
|
||||
if p, ok := findBin("frps"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 frps/frpc 二进制,可生成配置但无法启动"
|
||||
if p, ok := findBin("frpc"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 frps/frpc 二进制,点击「下载部署」可自动从 GitHub 下载"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
@@ -106,8 +123,10 @@ func validateProxy(p Proxy) error {
|
||||
if !safeName.MatchString(p.Name) {
|
||||
return fmt.Errorf("代理名称 %q 无效", p.Name)
|
||||
}
|
||||
if p.Type != "tcp" && p.Type != "udp" && p.Type != "http" && p.Type != "https" {
|
||||
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http 或 https", p.Name)
|
||||
switch p.Type {
|
||||
case "tcp", "udp", "http", "https", "stcp", "xtcp":
|
||||
default:
|
||||
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http、https、stcp 或 xtcp", p.Name)
|
||||
}
|
||||
if p.LocalPort < 1 || p.LocalPort > 65535 {
|
||||
return fmt.Errorf("代理 %s 的 local_port 无效", p.Name)
|
||||
@@ -124,6 +143,15 @@ func validateProxy(p Proxy) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if p.Type == "stcp" || p.Type == "xtcp" {
|
||||
if p.SecretKey == "" {
|
||||
return fmt.Errorf("STCP/XTCP 代理 %s 必须填写 secret_key", p.Name)
|
||||
}
|
||||
if strings.ContainsAny(p.SecretKey, "\r\n\"'") {
|
||||
return fmt.Errorf("代理 %s 的 secret_key 非法", p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(p.CustomDomains) == 0 && p.Subdomain == "" {
|
||||
return fmt.Errorf("HTTP/HTTPS 代理 %s 必须填写 custom_domains 或 subdomain", p.Name)
|
||||
}
|
||||
@@ -218,9 +246,12 @@ func (s *Service) frpcConfig(cfg map[string]interface{}, token string) string {
|
||||
rules, _ := proxyRules(cfg)
|
||||
for _, p := range rules {
|
||||
fmt.Fprintf(&b, "\n[[proxies]]\nname = %q\ntype = %q\nlocalIP = %q\nlocalPort = %d\n", p.Name, p.Type, defaultString(p.LocalIP, "127.0.0.1"), p.LocalPort)
|
||||
if p.Type == "tcp" || p.Type == "udp" {
|
||||
switch p.Type {
|
||||
case "tcp", "udp":
|
||||
fmt.Fprintf(&b, "remotePort = %d\n", p.RemotePort)
|
||||
} else {
|
||||
case "stcp", "xtcp":
|
||||
fmt.Fprintf(&b, "secretKey = %q\n", p.SecretKey)
|
||||
default:
|
||||
if len(p.CustomDomains) > 0 {
|
||||
fmt.Fprintf(&b, "customDomains = [%s]\n", strings.Join(quoteStrings(p.CustomDomains), ", "))
|
||||
}
|
||||
@@ -262,10 +293,10 @@ func (s *Service) Start(t *model.Tunnel) error {
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin, conf = "frps", "frps.toml"
|
||||
}
|
||||
if p, ok := service.LookBin(bin); ok {
|
||||
if p, ok := findBin(bin); ok {
|
||||
return service.StartDaemon(t, p, "-c", fmt.Sprintf("%s/%s", service.InstanceDir(t), conf))
|
||||
}
|
||||
return fmt.Errorf("无法启动 FRP:未检测到 %s", bin)
|
||||
return fmt.Errorf("无法启动 FRP:未检测到 %s,点击「下载部署」自动安装", bin)
|
||||
}
|
||||
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
@@ -277,7 +308,7 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin = "frps"
|
||||
}
|
||||
if _, ok := service.LookBin(bin); !ok {
|
||||
if _, ok := findBin(bin); !ok {
|
||||
return service.NowStatus(false, "undeployed", fmt.Sprintf("配置已生成,但未检测到 %s,尚未部署", bin)), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
@@ -296,7 +327,7 @@ func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("proxies 中每项必须是对象")
|
||||
}
|
||||
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite")}
|
||||
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite"), SecretKey: service.Str(m, "secret_key")}
|
||||
p.CustomDomains = stringList(m["custom_domains"])
|
||||
p.Locations = stringList(m["locations"])
|
||||
rules = append(rules, p)
|
||||
@@ -304,14 +335,14 @@ func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
|
||||
return rules, nil
|
||||
}
|
||||
if name := service.Str(cfg, "proxy_name"); name != "" {
|
||||
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains"))}}, nil
|
||||
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains")), SecretKey: service.Str(cfg, "secret_key")}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func proxiesJSON(in []Proxy) []interface{} {
|
||||
out := make([]interface{}, 0, len(in))
|
||||
for _, p := range in {
|
||||
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite})
|
||||
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite, "secret_key": p.SecretKey})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// Package ikev2 实现 IKEv2 远程接入(Road Warrior):员工手机/笔记本拨号进内网。
|
||||
// 依赖 strongSwan swanctl;认证为「服务器 PSK + 用户 EAP-MSCHAPv2」。
|
||||
// 拨号用户来自 VPNUser.DialSecret(可逆加密)与隧道级默认用户。
|
||||
package ikev2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
func (s *Service) Name() string { return "ikev2" }
|
||||
func (s *Service) DisplayName() string { return "IKEv2 远程接入" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("swanctl"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if service.Str(out, "address_pool") == "" {
|
||||
out["address_pool"] = "10.12.0.0/24"
|
||||
}
|
||||
if _, err := netip.ParsePrefix(service.Str(out, "address_pool")); err != nil {
|
||||
return nil, fmt.Errorf("address_pool 必须是 CIDR,例如 10.12.0.0/24")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if service.Str(out, "local_id") == "" {
|
||||
out["local_id"] = "@opsx-ikev2"
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048"
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = "aes256-sha256"
|
||||
}
|
||||
if service.Str(out, "local_ts") == "" {
|
||||
out["local_ts"] = "0.0.0.0/0"
|
||||
}
|
||||
for _, key := range []string{"local_id", "ike_proposal", "esp_proposal", "local_ts"} {
|
||||
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
|
||||
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
|
||||
}
|
||||
}
|
||||
if service.Str(out, "username") != "" && strings.ContainsAny(service.Str(out, "username"), " \t\r\n\"") {
|
||||
return nil, fmt.Errorf("username 不能包含空白或引号")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
if psk := service.Str(cfg, "psk"); psk != "" {
|
||||
secrets["psk"] = psk
|
||||
} else if service.Str(secrets, "psk") == "" {
|
||||
psk, err := service.RandomSecret(24)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["psk"] = psk
|
||||
}
|
||||
if pw := service.Str(cfg, "password"); pw != "" {
|
||||
secrets["password"] = pw
|
||||
}
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
|
||||
// 自动生成一个默认拨号密码,避免空库无法联调。
|
||||
pw, err := service.RandomSecret(12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["password"] = pw
|
||||
}
|
||||
|
||||
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), username, service.Str(secrets, "password"), dialUsers)
|
||||
if _, err := service.WriteConfig(t, "swanctl-ikev2.conf", conf); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets, username)); err != nil {
|
||||
return err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "psk", "password")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// RefreshAuth rewrites EAP secrets after dial-user changes.
|
||||
func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) }
|
||||
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []dialUser) string {
|
||||
var secrets strings.Builder
|
||||
fmt.Fprintf(&secrets, `secrets {
|
||||
ike-rw-%d {
|
||||
id = %s
|
||||
secret = %s
|
||||
}
|
||||
`, id, service.Str(cfg, "local_id"), strconv.Quote(psk))
|
||||
seen := map[string]bool{}
|
||||
if defaultUser != "" && defaultPass != "" {
|
||||
fmt.Fprintf(&secrets, ` eap-%s-%d {
|
||||
id = %s
|
||||
secret = %s
|
||||
}
|
||||
`, defaultUser, id, strconv.Quote(defaultUser), strconv.Quote(defaultPass))
|
||||
seen[defaultUser] = true
|
||||
}
|
||||
for i, u := range users {
|
||||
if u.Username == "" || u.Password == "" || seen[u.Username] {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&secrets, ` eap-user-%d-%d {
|
||||
id = %s
|
||||
secret = %s
|
||||
}
|
||||
`, id, i, strconv.Quote(u.Username), strconv.Quote(u.Password))
|
||||
seen[u.Username] = true
|
||||
}
|
||||
secrets.WriteString("}\n")
|
||||
|
||||
return fmt.Sprintf(`connections {
|
||||
ikev2-rw-%d {
|
||||
version = 2
|
||||
pools = pool-%d
|
||||
proposals = %s
|
||||
dpd_delay = 30s
|
||||
dpd_timeout = 120s
|
||||
local {
|
||||
auth = psk
|
||||
id = %s
|
||||
}
|
||||
remote {
|
||||
auth = eap-mschapv2
|
||||
eap_id = %%any
|
||||
}
|
||||
children {
|
||||
net-%d {
|
||||
local_ts = %s
|
||||
esp_proposals = %s
|
||||
dpd_action = clear
|
||||
start_action = none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pools {
|
||||
pool-%d {
|
||||
addrs = %s
|
||||
dns = %s
|
||||
}
|
||||
}
|
||||
%s`,
|
||||
id,
|
||||
id,
|
||||
service.Str(cfg, "ike_proposal"),
|
||||
service.Str(cfg, "local_id"),
|
||||
id,
|
||||
service.Str(cfg, "local_ts"),
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
id,
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
secrets.String(),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) clientGuide(cfg, secrets map[string]interface{}, username string) string {
|
||||
return fmt.Sprintf(`======== IKEv2 远程接入参数 ========
|
||||
|
||||
服务器地址 : %s
|
||||
VPN 类型 : IKEv2
|
||||
远程 ID / 服务器 ID : %s
|
||||
本地 ID : (可留空,或填用户名)
|
||||
机器认证 : 预共享密钥 (PSK)
|
||||
预共享密钥 : %s
|
||||
用户认证 : 用户名 + 密码(EAP-MSCHAPv2)
|
||||
默认用户名 : %s
|
||||
默认密码 : %s
|
||||
地址池 : %s
|
||||
DNS : %s
|
||||
|
||||
客户端配置指引:
|
||||
- iOS/macOS : 设置 → 通用 → VPN 与设备管理 → VPN → 添加 IKEv2
|
||||
服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK
|
||||
- Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2
|
||||
- Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP
|
||||
- 更多账号 : 在平台「VPN 拨号用户」中为本隧道添加用户后重新生成配置
|
||||
|
||||
注意:修改拨号用户后需在专线列表对该隧道执行「停止 → 启动」或重新保存配置以刷新 EAP 密钥。
|
||||
`,
|
||||
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP 或域名>"),
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(secrets, "psk"),
|
||||
username,
|
||||
service.Str(secrets, "password"),
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
return s.clientGuide(cfg, secrets, service.StrOr(cfg, "username", "vpnuser")), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
confPath := service.InstanceDir(t) + "/swanctl-ikev2.conf"
|
||||
bin, ok := service.LookBin("swanctl")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IKEv2:未检测到 swanctl,请先安装 strongSwan")
|
||||
}
|
||||
if err := service.RequireRoot("IKEv2"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("IKEv2"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(confPath); err != nil {
|
||||
return fmt.Errorf("无法启动 IKEv2:配置文件不存在,请先重新生成配置: %w", err)
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.installFirewall(t, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.unload(bin, confPath)
|
||||
if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
var firstErr error
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
_, _ = service.Run(bin, "--terminate", "--ike", fmt.Sprintf("ikev2-rw-%d", t.ID))
|
||||
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-ikev2.conf"); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if cfg, err := store.TunnelConfig(t); err == nil {
|
||||
if err := s.removeFirewall(t, cfg); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
// Road Warrior 多数时候无固定 SA;配置已加载即视为服务就绪。
|
||||
out, err := service.Run(bin, "--list-conns")
|
||||
name := fmt.Sprintf("ikev2-rw-%d", t.ID)
|
||||
if err != nil {
|
||||
// list-conns 失败时再查 SA
|
||||
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil && strings.Contains(sas, name) {
|
||||
return service.NowStatus(true, "running", "IKEv2 远程接入运行中(有已建立会话)"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "无法确认 IKEv2 连接定义是否已加载"), nil
|
||||
}
|
||||
if strings.Contains(out, name) {
|
||||
detail := "IKEv2 远程接入已加载,等待客户端拨号"
|
||||
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil {
|
||||
// 粗略统计会话数
|
||||
n := strings.Count(sas, name)
|
||||
if n > 0 {
|
||||
detail = fmt.Sprintf("IKEv2 远程接入运行中,约 %d 个相关 SA", n)
|
||||
}
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "IKEv2 连接定义未加载"), nil
|
||||
}
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IKEv2:未检测到 iptables")
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
var err error
|
||||
outbound, err = service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rules := []struct {
|
||||
table, chain string
|
||||
args []string
|
||||
}{
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.chain)
|
||||
check = append(check, rule.args...)
|
||||
if _, err := service.Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append(append([]string{}, prefix...), "-A", rule.chain)
|
||||
add = append(add, rule.args...)
|
||||
if _, err := service.Run(iptables, add...); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return fmt.Errorf("无法添加 IKEv2 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
outbound, _ = service.DefaultRouteInterface()
|
||||
}
|
||||
rules := []struct {
|
||||
table, chain string
|
||||
args []string
|
||||
}{
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.table)
|
||||
}
|
||||
for {
|
||||
check := append(append([]string{}, prefix...), "-C", rule.chain)
|
||||
check = append(check, rule.args...)
|
||||
if _, err := service.Run(iptables, check...); err != nil {
|
||||
break
|
||||
}
|
||||
remove := append(append([]string{}, prefix...), "-D", rule.chain)
|
||||
remove = append(remove, rule.args...)
|
||||
if _, err := service.Run(iptables, remove...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ikev2
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateDefaults(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{
|
||||
"public_endpoint": "1.2.3.4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["address_pool"] != "10.12.0.0/24" || cfg["local_id"] != "@opsx-ikev2" {
|
||||
t.Fatalf("defaults: %#v", cfg)
|
||||
}
|
||||
if _, err := New().Validate(map[string]interface{}{"address_pool": "not-a-cidr"}); err == nil {
|
||||
t.Fatal("expected invalid pool error")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,27 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
// 厂商模板优先填空字段,再回落到通用默认值。
|
||||
if key := service.Str(out, "vendor_template"); key != "" {
|
||||
for _, tmpl := range Templates() {
|
||||
if tmpl.Key != key {
|
||||
continue
|
||||
}
|
||||
if service.Str(out, "ike_version") == "" {
|
||||
out["ike_version"] = tmpl.IKEVersion
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = tmpl.IKEProposal
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = tmpl.ESPProposal
|
||||
}
|
||||
if service.Str(out, "dpd_action") == "" {
|
||||
out["dpd_action"] = tmpl.DPDAction
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if service.Str(out, "ike_version") == "" {
|
||||
out["ike_version"] = "ikev2"
|
||||
}
|
||||
@@ -77,6 +98,12 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if service.Str(out, "remote_id") == "" {
|
||||
out["remote_id"] = "@vpn-remote"
|
||||
}
|
||||
if service.Num(out, "ike_lifetime", 0) == 0 {
|
||||
out["ike_lifetime"] = 86400
|
||||
}
|
||||
if service.Str(out, "pfs_group") == "" {
|
||||
out["pfs_group"] = "modp2048"
|
||||
}
|
||||
remoteGateway := service.Str(out, "remote_gateway")
|
||||
// StrongSwan accepts both literal addresses and DNS names. Keep DNS support
|
||||
// for peers with dynamic addresses, while rejecting syntax that can alter the
|
||||
@@ -347,18 +374,73 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
out, err := service.Run(bin, "--list-sas")
|
||||
child := fmt.Sprintf("site-%d", t.ID)
|
||||
if err == nil && strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
|
||||
st := service.NowStatus(true, "running", "IKE/Child SA 已建立")
|
||||
return st, nil
|
||||
}
|
||||
ike := fmt.Sprintf("tunnel-%d", t.ID)
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil
|
||||
}
|
||||
if strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
|
||||
detail := "IKE/Child SA 已建立"
|
||||
// 从 swanctl 输出中尽量提取对端地址与字节计数(格式因版本略有差异)。
|
||||
if peer := extractSwanPeer(out, ike); peer != "" {
|
||||
detail += ",对端 " + peer
|
||||
}
|
||||
if rx, tx, ok := extractSwanBytes(out, child); ok {
|
||||
st := service.NowStatus(true, "running", detail)
|
||||
st.RxBytes = rx
|
||||
st.TxBytes = tx
|
||||
return st, nil
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
if strings.Contains(out, ike) {
|
||||
return service.NowStatus(false, "stopped", "IKE SA 存在但 Child SA 未 INSTALLED"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), nil
|
||||
}
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
|
||||
}
|
||||
|
||||
func extractSwanPeer(out, ikeName string) string {
|
||||
// 典型行: tunnel-1: #1, ESTABLISHED, ... 1.2.3.4[... ]...5.6.7.8[...]
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, ikeName) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
for _, f := range fields {
|
||||
if strings.Contains(f, "[") && strings.Count(f, ".") >= 3 {
|
||||
return strings.Split(f, "[")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractSwanBytes(out, childName string) (rx, tx int64, ok bool) {
|
||||
// 寻找含 child 名与 bytes_i/bytes_o 的行
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, childName) {
|
||||
continue
|
||||
}
|
||||
// 兼容 "123 bytes_i, 456 bytes_o" 与 "bytes_i/bytes_o"
|
||||
parts := strings.Fields(line)
|
||||
for i, p := range parts {
|
||||
if strings.Contains(p, "bytes_i") && i > 0 {
|
||||
fmt.Sscanf(parts[i-1], "%d", &rx)
|
||||
ok = true
|
||||
}
|
||||
if strings.Contains(p, "bytes_o") && i > 0 {
|
||||
fmt.Sscanf(parts[i-1], "%d", &tx)
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
|
||||
@@ -106,8 +106,14 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
if pw := service.Str(cfg, "password"); pw != "" {
|
||||
secrets["password"] = pw
|
||||
} else if service.Str(secrets, "password") == "" {
|
||||
return fmt.Errorf("缺少拨号密码 password")
|
||||
}
|
||||
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
|
||||
return fmt.Errorf("缺少拨号密码:请填写隧道默认 password,或在「VPN 拨号用户」中添加本隧道用户")
|
||||
}
|
||||
|
||||
// 1) IPSec 层(transport 模式,L2TP over IPSec)
|
||||
@@ -172,9 +178,7 @@ mru 1400
|
||||
require-mschap-v2
|
||||
`, service.Str(cfg, "dns"))
|
||||
|
||||
chapSecrets := fmt.Sprintf(`# client server secret IP addresses
|
||||
%s l2tpd %s *
|
||||
`, username, strconv.Quote(service.Str(secrets, "password")))
|
||||
chapSecrets := buildChapSecrets(username, service.Str(secrets, "password"), dialUsers)
|
||||
|
||||
for name, content := range map[string]string{
|
||||
"swanctl-l2tp.conf": swanConf,
|
||||
@@ -193,6 +197,56 @@ require-mschap-v2
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// RefreshAuth rewrites chap-secrets after VPN dial users change.
|
||||
func (s *Service) RefreshAuth(t *model.Tunnel) error {
|
||||
return s.Create(t)
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildChapSecrets(defaultUser, defaultPass string, users []dialUser) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# client server secret IP addresses\n")
|
||||
seen := map[string]bool{}
|
||||
if defaultUser != "" && defaultPass != "" {
|
||||
fmt.Fprintf(&b, "%s l2tpd %s *\n", defaultUser, strconv.Quote(defaultPass))
|
||||
seen[defaultUser] = true
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Username == "" || u.Password == "" || seen[u.Username] {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s l2tpd %s *\n", u.Username, strconv.Quote(u.Password))
|
||||
seen[u.Username] = true
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GenerateClientConfig 导出客户端接入说明(手机/Windows 手动配置)
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -15,18 +17,30 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/sessionmgr"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// TOTP 参数(RFC 6238 标准:sha1 / 30s / 6 位 / base32),兼容主流 Authenticator 应用。
|
||||
const (
|
||||
otpPeriod = 30
|
||||
otpDigits = 6
|
||||
otpDefaultSlop = 90
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
var certificateName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
@@ -34,11 +48,21 @@ var certificateName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
func New() *Service { return &Service{} }
|
||||
func (s *Service) Name() string { return "openvpn" }
|
||||
func (s *Service) DisplayName() string { return "OpenVPN" }
|
||||
|
||||
// findOpenVPNBin 查找 openvpn 二进制:优先 data/bin/(平台下载),其次 PATH(系统安装)
|
||||
func findOpenVPNBin() (string, bool) {
|
||||
managed := filepath.Join(service.BaseDir(), "bin", "openvpn")
|
||||
if fi, err := os.Stat(managed); err == nil && !fi.IsDir() {
|
||||
return managed, true
|
||||
}
|
||||
return service.LookBin("openvpn")
|
||||
}
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("openvpn"); ok {
|
||||
if p, ok := findOpenVPNBin(); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 openvpn 二进制,可生成配置但无法启动"
|
||||
return false, "未检测到 openvpn 二进制,点击「下载部署」可自动从 GitHub 下载"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
@@ -55,8 +79,14 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if service.Str(out, "proto") == "" {
|
||||
out["proto"] = "udp"
|
||||
}
|
||||
if p := service.Str(out, "proto"); p != "udp" && p != "tcp-server" {
|
||||
return nil, fmt.Errorf("proto 必须为 udp 或 tcp-server")
|
||||
// 前端/API 可用 udp | tcp | tcp-server;服务端统一写成 udp 或 tcp-server。
|
||||
switch strings.ToLower(service.Str(out, "proto")) {
|
||||
case "tcp", "tcp-server", "tcp-client":
|
||||
out["proto"] = "tcp-server"
|
||||
case "udp":
|
||||
out["proto"] = "udp"
|
||||
default:
|
||||
return nil, fmt.Errorf("proto 必须为 udp 或 tcp")
|
||||
}
|
||||
if service.Str(out, "subnet") == "" {
|
||||
out["subnet"] = "10.10.0.0 255.255.255.0"
|
||||
@@ -84,9 +114,126 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
default:
|
||||
return nil, fmt.Errorf("auth_mode 必须为 certificate、password 或 certificate+password")
|
||||
}
|
||||
// MFA 模式:off 关闭;optional 用户启用后校验;required 强制。
|
||||
if service.Str(out, "mfa_mode") == "" {
|
||||
if service.Bool(out, "require_mfa", false) {
|
||||
out["mfa_mode"] = "required"
|
||||
} else {
|
||||
out["mfa_mode"] = "off"
|
||||
}
|
||||
}
|
||||
switch service.Str(out, "mfa_mode") {
|
||||
case "off", "optional", "required":
|
||||
default:
|
||||
return nil, fmt.Errorf("mfa_mode 必须为 off、optional 或 required")
|
||||
}
|
||||
if service.Str(out, "mfa_mode") != "off" {
|
||||
mode := service.Str(out, "auth_mode")
|
||||
if mode != "password" && mode != "certificate+password" {
|
||||
return nil, fmt.Errorf("启用 MFA 需要认证方式为「仅用户名密码」或「证书 + 密码」")
|
||||
}
|
||||
}
|
||||
// otp_slop:允许时钟偏差秒数(默认 180),换算为 TOTP skew 窗口数。
|
||||
if service.Num(out, "otp_slop", 0) == 0 {
|
||||
out["otp_slop"] = 90
|
||||
}
|
||||
if slop := int(service.Num(out, "otp_slop", 90)); slop < 0 || slop > 600 {
|
||||
return nil, fmt.Errorf("otp_slop 必须在 0-600 秒")
|
||||
}
|
||||
if service.Num(out, "otp_max_failures", 0) == 0 {
|
||||
out["otp_max_failures"] = 5
|
||||
}
|
||||
if service.Num(out, "otp_lockout_sec", 0) == 0 {
|
||||
out["otp_lockout_sec"] = 300
|
||||
}
|
||||
if _, ok := out["otp_disallow_reuse"]; !ok {
|
||||
out["otp_disallow_reuse"] = true
|
||||
}
|
||||
if _, ok := out["otp_password_is_cr"]; !ok {
|
||||
// 默认 password_is_cr=1:期望 static-challenge / SCRV1 响应格式。
|
||||
out["otp_password_is_cr"] = true
|
||||
}
|
||||
if service.Str(out, "push_routes") != "" {
|
||||
for _, r := range strings.Split(service.Str(out, "push_routes"), ",") {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(r); err != nil {
|
||||
return nil, fmt.Errorf("push_routes 含非法 CIDR: %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := out["redirect_gateway"]; !ok {
|
||||
// 未填 push_routes 时默认全流量;有 push_routes 时默认仅推内网。
|
||||
out["redirect_gateway"] = service.Str(out, "push_routes") == ""
|
||||
}
|
||||
if _, ok := out["pki_auto_init"]; !ok {
|
||||
out["pki_auto_init"] = true
|
||||
}
|
||||
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
|
||||
if iface, err := service.DefaultRouteInterface(); err == nil {
|
||||
out["egress_interface"] = iface
|
||||
}
|
||||
}
|
||||
// —— 生产 server.conf 常用调优(可借鉴参数)——
|
||||
if service.Num(out, "keepalive_interval", 0) == 0 {
|
||||
out["keepalive_interval"] = 5 // 生产常见 5 30,比默认 10 120 更灵敏
|
||||
}
|
||||
if service.Num(out, "keepalive_timeout", 0) == 0 {
|
||||
out["keepalive_timeout"] = 30
|
||||
}
|
||||
ki, kt := int(service.Num(out, "keepalive_interval", 5)), int(service.Num(out, "keepalive_timeout", 30))
|
||||
if ki < 1 || ki > 3600 || kt < ki || kt > 7200 {
|
||||
return nil, fmt.Errorf("keepalive 参数无效:interval 1-3600,timeout >= interval 且 ≤7200")
|
||||
}
|
||||
if service.Num(out, "tun_mtu", 0) == 0 {
|
||||
out["tun_mtu"] = 1500
|
||||
}
|
||||
if mtu := int(service.Num(out, "tun_mtu", 1500)); mtu < 576 || mtu > 9000 {
|
||||
return nil, fmt.Errorf("tun_mtu 必须在 576-9000")
|
||||
}
|
||||
if service.Num(out, "tun_mtu_extra", 0) == 0 {
|
||||
out["tun_mtu_extra"] = 32
|
||||
}
|
||||
// TCP 场景生产常用 mssfix 1450,避免分片;UDP 也可设。0 表示不写该指令。
|
||||
if _, ok := out["mssfix"]; !ok {
|
||||
if service.Str(out, "proto") == "tcp-server" {
|
||||
out["mssfix"] = 1450
|
||||
} else {
|
||||
out["mssfix"] = 0
|
||||
}
|
||||
}
|
||||
if mf := int(service.Num(out, "mssfix", 0)); mf != 0 && (mf < 500 || mf > 9000) {
|
||||
return nil, fmt.Errorf("mssfix 为 0(禁用)或 500-9000")
|
||||
}
|
||||
if _, ok := out["duplicate_cn"]; !ok {
|
||||
// 密码拨号多终端同用户名时生产常开;证书模式默认关(安全)。
|
||||
out["duplicate_cn"] = service.Str(out, "auth_mode") == "password"
|
||||
}
|
||||
if _, ok := out["reneg_sec"]; !ok {
|
||||
// 生产常见 reneg-sec 0;MFA/密码场景避免周期性 TLS 重协商。
|
||||
if service.Str(out, "auth_mode") != "certificate" || service.Str(out, "mfa_mode") != "off" {
|
||||
out["reneg_sec"] = 0
|
||||
} else {
|
||||
out["reneg_sec"] = 3600
|
||||
}
|
||||
}
|
||||
if rs := int(service.Num(out, "reneg_sec", 0)); rs < 0 || rs > 86400 {
|
||||
return nil, fmt.Errorf("reneg_sec 必须在 0-86400")
|
||||
}
|
||||
if service.Num(out, "verb", 0) == 0 {
|
||||
out["verb"] = 3
|
||||
}
|
||||
// 可选降权(生产 user openvpn / group openvpn);空则不写,避免非 root 启动失败。
|
||||
for _, key := range []string{"run_as_user", "run_as_group"} {
|
||||
if v := service.Str(out, key); v != "" && !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`).MatchString(v) {
|
||||
return nil, fmt.Errorf("%s 非法", key)
|
||||
}
|
||||
}
|
||||
if service.Num(out, "max_clients", 0) == 0 {
|
||||
// 0 表示不限制;生产常设 1000,这里不强制默认值以免改变既有语义。
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -125,7 +272,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
}
|
||||
// PKI creation is intentionally best-effort: a missing easy-rsa must not be
|
||||
// reported as a running server. Start() verifies every required artifact.
|
||||
_, openvpnInstalled := service.LookBin("openvpn")
|
||||
_, openvpnInstalled := findOpenVPNBin()
|
||||
if service.Bool(cfg, "pki_auto_init", true) && easyRSAPath() != "" && openvpnInstalled && pkiReady(t) != nil {
|
||||
return s.InitializePKI(t)
|
||||
}
|
||||
@@ -136,14 +283,66 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
|
||||
dir := service.InstanceDir(t)
|
||||
ca, cert, key, dh, ta := filepath.Join(dir, "pki", "ca.crt"), filepath.Join(dir, "pki", "issued", "server.crt"), filepath.Join(dir, "pki", "private", "server.key"), filepath.Join(dir, "pki", "dh.pem"), filepath.Join(dir, "ta.key")
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\nca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp"), ca, cert, key, dh, ta)
|
||||
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\npush \"dhcp-option DNS %s\"\npush \"redirect-gateway def1 bypass-dhcp\"\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"), service.StrOr(cfg, "dns", "223.5.5.5"))
|
||||
fmt.Fprintf(&b, "keepalive 10 120\ndata-ciphers %s:AES-128-GCM\npersist-key\npersist-tun\nstatus %q 10\nstatus-version 3\nverb 3\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), filepath.Join(dir, "openvpn-status.log"))
|
||||
|
||||
// 基础监听(对齐生产:proto/port/dev tun)
|
||||
fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp"))
|
||||
// MTU / MSS(生产:tun-mtu 1500、tun-mtu-extra 32、mssfix 1450)
|
||||
fmt.Fprintf(&b, "tun-mtu %d\n", int(service.Num(cfg, "tun_mtu", 1500)))
|
||||
if extra := int(service.Num(cfg, "tun_mtu_extra", 32)); extra > 0 {
|
||||
fmt.Fprintf(&b, "tun-mtu-extra %d\n", extra)
|
||||
}
|
||||
if mf := int(service.Num(cfg, "mssfix", 0)); mf > 0 {
|
||||
fmt.Fprintf(&b, "mssfix %d\n", mf)
|
||||
}
|
||||
// 证书与 TLS(crl-verify:吊销即时生效,对齐生产加固)
|
||||
crl := filepath.Join(dir, "pki", "crl.pem")
|
||||
fmt.Fprintf(&b, "ca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\ncrl-verify %q\n", ca, cert, key, dh, ta, crl)
|
||||
// 地址池与拓扑
|
||||
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"))
|
||||
// push:DNS / 全流量 / 内网路由
|
||||
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", "223.5.5.5"))
|
||||
if service.Bool(cfg, "redirect_gateway", true) {
|
||||
b.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
|
||||
}
|
||||
for _, r := range strings.Split(service.Str(cfg, "push_routes"), ",") {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
ip, network, err := net.ParseCIDR(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mask := net.IP(network.Mask).String()
|
||||
fmt.Fprintf(&b, "push \"route %s %s\"\n", ip.Mask(network.Mask).String(), mask)
|
||||
}
|
||||
// 会话保活(生产 keepalive 5 30)
|
||||
fmt.Fprintf(&b, "keepalive %d %d\n", int(service.Num(cfg, "keepalive_interval", 5)), int(service.Num(cfg, "keepalive_timeout", 30)))
|
||||
fmt.Fprintf(&b, "data-ciphers %s:AES-128-GCM\npersist-key\npersist-tun\n", service.StrOr(cfg, "cipher", "AES-256-GCM"))
|
||||
// reneg-sec:生产/MFA 常 0,避免周期性 TLS 重协商
|
||||
fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0)))
|
||||
if service.Bool(cfg, "duplicate_cn", false) {
|
||||
b.WriteString("duplicate-cn\n")
|
||||
}
|
||||
if maxClients := int(service.Num(cfg, "max_clients", 0)); maxClients > 0 {
|
||||
fmt.Fprintf(&b, "max-clients %d\n", maxClients)
|
||||
}
|
||||
// 可选降权(生产 user/group openvpn)
|
||||
if u := service.Str(cfg, "run_as_user"); u != "" {
|
||||
fmt.Fprintf(&b, "user %s\n", u)
|
||||
}
|
||||
if g := service.Str(cfg, "run_as_group"); g != "" {
|
||||
fmt.Fprintf(&b, "group %s\n", g)
|
||||
}
|
||||
fmt.Fprintf(&b, "status %q 10\nstatus-version 3\nverb %d\n", filepath.Join(dir, "openvpn-status.log"), int(service.Num(cfg, "verb", 3)))
|
||||
if service.StrOr(cfg, "proto", "udp") == "udp" {
|
||||
b.WriteString("explicit-exit-notify 1\n")
|
||||
}
|
||||
|
||||
// 认证
|
||||
switch service.StrOr(cfg, "auth_mode", "certificate") {
|
||||
case "password":
|
||||
// 对齐生产:verify-client-cert none + username-as-common-name(client-cert-not-required 已弃用)
|
||||
b.WriteString("verify-client-cert none\nusername-as-common-name\n")
|
||||
case "certificate+password":
|
||||
b.WriteString("verify-client-cert require\n")
|
||||
@@ -151,7 +350,12 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
|
||||
b.WriteString("verify-client-cert require\n")
|
||||
}
|
||||
if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "certificate+password" {
|
||||
// via-file 比 via-env 更安全(密码不进环境变量);script-security 2 足够执行外部脚本。
|
||||
// 生产若用 via-env 需 script-security 3,我们刻意保持 via-file + 2。
|
||||
fmt.Fprintf(&b, "script-security 2\nauth-user-pass-verify %q via-file\n", filepath.Join(dir, "auth-user-pass.sh"))
|
||||
if mfaMode := service.StrOr(cfg, "mfa_mode", "off"); mfaMode == "optional" || mfaMode == "required" {
|
||||
b.WriteString("static-challenge \"请输入 Google Authenticator / MFA 验证码\" 1\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -185,7 +389,7 @@ func (s *Service) InitializePKI(t *model.Tunnel) error {
|
||||
if err := runEasyRSA(dir, easy, "gen-crl"); err != nil {
|
||||
return err
|
||||
}
|
||||
openvpn, ok := service.LookBin("openvpn")
|
||||
openvpn, ok := findOpenVPNBin()
|
||||
if !ok {
|
||||
return fmt.Errorf("PKI 证书已生成,但未检测到 openvpn,无法生成 tls-crypt 密钥")
|
||||
}
|
||||
@@ -245,6 +449,34 @@ func authScript(t *model.Tunnel) string {
|
||||
}
|
||||
func shellQuote(v string) string { return "'" + strings.ReplaceAll(v, "'", "'\\''") + "'" }
|
||||
|
||||
// RevokeClientCertificate 吊销用户证书并重新生成 CRL(禁用/删除拨号用户时调用)。
|
||||
// server.conf 的 crl-verify 使吊销生效;运行中进程收 SIGUSR1 重读 CRL。
|
||||
func (s *Service) RevokeClientCertificate(t *model.Tunnel, user *model.VPNUser) error {
|
||||
if user.Certificate == "" {
|
||||
return nil
|
||||
}
|
||||
easy := easyRSAPath()
|
||||
if easy == "" {
|
||||
return fmt.Errorf("未检测到 easy-rsa,无法吊销证书")
|
||||
}
|
||||
dir := service.InstanceDir(t)
|
||||
if _, err := os.Stat(filepath.Join(dir, "pki", "issued", user.Certificate+".crt")); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "--batch", "revoke", user.Certificate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "gen-crl"); err != nil {
|
||||
return err
|
||||
}
|
||||
if pid, alive := service.ReadPid(t); alive {
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Signal(syscall.SIGUSR1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueClientCertificate signs a per-VPNUser certificate and records the exact
|
||||
// certificate CN. LDAP users can use certificates as a second factor too.
|
||||
func (s *Service) IssueClientCertificate(t *model.Tunnel, user *model.VPNUser) error {
|
||||
@@ -333,6 +565,15 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
|
||||
fmt.Fprintf(&b, "client\ndev tun\nproto %s\nremote %s %d\nresolv-retry infinite\nnobind\npersist-key\npersist-tun\nremote-cert-tls server\ndata-ciphers %s:AES-128-GCM\nverb 3\n", clientProto(service.StrOr(cfg, "proto", "udp")), service.StrOr(cfg, "public_endpoint", "your-server-ip"), int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "cipher", "AES-256-GCM"))
|
||||
if mode == "password" || mode == "certificate+password" {
|
||||
b.WriteString("auth-user-pass\n")
|
||||
b.WriteString("auth-nocache\n")
|
||||
if mfaMode := service.StrOr(cfg, "mfa_mode", "off"); mfaMode == "optional" || mfaMode == "required" {
|
||||
b.WriteString("static-challenge \"请输入 Google Authenticator / MFA 验证码\" 1\n")
|
||||
}
|
||||
}
|
||||
// 与服务端 reneg-sec 一致(生产常 0)
|
||||
fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0)))
|
||||
if mtu := int(service.Num(cfg, "tun_mtu", 0)); mtu > 0 {
|
||||
fmt.Fprintf(&b, "tun-mtu %d\n", mtu)
|
||||
}
|
||||
b.WriteString("<ca>\n" + ca + "</ca>\n")
|
||||
if cert != "" {
|
||||
@@ -342,10 +583,12 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
|
||||
return b.String(), nil
|
||||
}
|
||||
func clientProto(proto string) string {
|
||||
if proto == "tcp-server" {
|
||||
switch strings.ToLower(proto) {
|
||||
case "tcp", "tcp-server", "tcp-client":
|
||||
return "tcp-client"
|
||||
default:
|
||||
return "udp"
|
||||
}
|
||||
return proto
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
@@ -365,16 +608,140 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
if _, ok := service.LookBin("openvpn"); !ok {
|
||||
if _, ok := findOpenVPNBin(); !ok {
|
||||
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
|
||||
}
|
||||
if err := pkiReady(t); err != nil {
|
||||
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
|
||||
}
|
||||
return service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf"))
|
||||
if err := service.RequireRoot("OpenVPN"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port := int(service.Num(cfg, "port", 1194))
|
||||
if service.StrOr(cfg, "proto", "udp") == "udp" {
|
||||
if err := service.RequireUDPPortAvailable(port, "OpenVPN"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := service.RequireTCPPortAvailable(port, "OpenVPN"); err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Bool(cfg, "enable_nat", false) {
|
||||
if err := s.installNAT(t, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil {
|
||||
_ = s.removeNAT(t, cfg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
var firstErr error
|
||||
if err := service.KillPid(t); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", false) {
|
||||
if err := s.removeNAT(t, cfg); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) installNAT(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 OpenVPN:启用 NAT 需要 iptables")
|
||||
}
|
||||
egress := service.Str(cfg, "egress_interface")
|
||||
if egress == "" {
|
||||
var err error
|
||||
egress, err = service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ip, mask, err := parseSubnet(service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ones, _ := mask.Size()
|
||||
subnet := fmt.Sprintf("%s/%d", ip.String(), ones)
|
||||
comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID)
|
||||
rules := [][]string{
|
||||
{"-t", "nat", "-A", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"},
|
||||
{"-A", "FORWARD", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
check := append([]string{}, rule...)
|
||||
// -C 检查时把 -A 换成 -C
|
||||
for i := 0; i < len(check); i++ {
|
||||
if check[i] == "-A" {
|
||||
check[i] = "-C"
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := service.Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := service.Run(iptables, rule...); err != nil {
|
||||
_ = s.removeNAT(t, cfg)
|
||||
return fmt.Errorf("无法添加 OpenVPN NAT 规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
egress := service.Str(cfg, "egress_interface")
|
||||
if egress == "" {
|
||||
egress, _ = service.DefaultRouteInterface()
|
||||
}
|
||||
ip, mask, err := parseSubnet(service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ones, _ := mask.Size()
|
||||
subnet := fmt.Sprintf("%s/%d", ip.String(), ones)
|
||||
comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID)
|
||||
candidates := [][]string{
|
||||
{"-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"},
|
||||
{"-D", "FORWARD", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-D", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range candidates {
|
||||
for {
|
||||
check := append([]string{}, rule...)
|
||||
for i := 0; i < len(check); i++ {
|
||||
if check[i] == "-D" {
|
||||
check[i] = "-C"
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := service.Run(iptables, check...); err != nil {
|
||||
break
|
||||
}
|
||||
if _, err := service.Run(iptables, rule...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
|
||||
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if _, alive := service.ReadPid(t); alive {
|
||||
st := service.NowStatus(true, "running", "OpenVPN 进程运行中")
|
||||
@@ -383,7 +750,7 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
st.TxBytes = t.TxBytes + tx
|
||||
return st, nil
|
||||
}
|
||||
if _, ok := service.LookBin("openvpn"); !ok {
|
||||
if _, ok := findOpenVPNBin(); !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 openvpn,尚未部署"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
@@ -407,6 +774,8 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
next := trafficSnapshot{}
|
||||
var totalRx, totalTx int64
|
||||
now := time.Now()
|
||||
// 收集在线客户端信息(供 sessionmgr 同步会话)
|
||||
var onlineClients []sessionmgr.ClientInfo
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(line, ",")
|
||||
if len(f) < 7 || f[0] != "CLIENT_LIST" {
|
||||
@@ -422,7 +791,12 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
if len(f) > 9 && f[9] != "" && f[9] != "UNDEF" {
|
||||
username = f[9]
|
||||
}
|
||||
key := username + "|" + f[2]
|
||||
realAddr := f[2]
|
||||
virtualAddr := ""
|
||||
if len(f) > 4 {
|
||||
virtualAddr = f[3]
|
||||
}
|
||||
key := username + "|" + realAddr
|
||||
old := snap[key]
|
||||
deltaRx, deltaTx := rx-old.Rx, tx-old.Tx
|
||||
if deltaRx < 0 {
|
||||
@@ -437,9 +811,16 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
if store.DB != nil && username != "" {
|
||||
store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND (username = ? OR certificate = ?)", t.ID, username, cn).Updates(map[string]interface{}{"rx_bytes": gorm.Expr("rx_bytes + ?", deltaRx), "tx_bytes": gorm.Expr("tx_bytes + ?", deltaTx), "last_seen_at": now})
|
||||
}
|
||||
// 收集在线客户端
|
||||
onlineClients = append(onlineClients, sessionmgr.ClientInfo{
|
||||
Username: username, RealAddress: realAddr, VirtualAddress: virtualAddr,
|
||||
RxBytes: rx, TxBytes: tx,
|
||||
})
|
||||
}
|
||||
b, _ := json.Marshal(next)
|
||||
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "traffic-state.json"), b, 0o600)
|
||||
// 同步在线会话到 VPNSession 表
|
||||
sessionmgr.SyncSessions(t.ID, "openvpn", onlineClients)
|
||||
return totalRx, totalTx
|
||||
}
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
@@ -447,6 +828,7 @@ func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
// RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's
|
||||
// auth-user-pass-verify hook. It returns 0 only for enabled, tunnel-scoped
|
||||
// local users or LDAP users whose recorded LDAP DN accepts the password.
|
||||
// MFA 支持:static-challenge(SCRV1 / 第三行)、或密码末尾附加 6 位 TOTP。
|
||||
func RunAuthHelper(args []string) error {
|
||||
fs := flag.NewFlagSet("openvpn-auth", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
@@ -466,16 +848,51 @@ func RunAuthHelper(args []string) error {
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) == "" {
|
||||
return errors.New("认证凭据格式无效")
|
||||
}
|
||||
return Authenticate(uint(*tunnel), strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]))
|
||||
username := strings.TrimSpace(lines[0])
|
||||
passwordField := strings.TrimSpace(lines[1])
|
||||
var challenge string
|
||||
if len(lines) >= 3 {
|
||||
challenge = strings.TrimSpace(lines[2])
|
||||
}
|
||||
return Authenticate(uint(*tunnel), username, passwordField, challenge)
|
||||
}
|
||||
func Authenticate(tunnelID uint, username, password string) error {
|
||||
if password == "" {
|
||||
|
||||
// Authenticate validates dial-in credentials for one OpenVPN tunnel.
|
||||
// password_is_cr 模式:密码与 OTP 分离(SCRV1 / static-challenge 响应格式);
|
||||
// 否则支持 password+OTP 拼接;response 可为 PIN+OTP。
|
||||
func Authenticate(tunnelID uint, username, passwordField, challenge string) error {
|
||||
if passwordField == "" {
|
||||
return errors.New("密码为空")
|
||||
}
|
||||
var tunnel model.Tunnel
|
||||
if err := store.DB.First(&tunnel, tunnelID).Error; err != nil {
|
||||
return errors.New("隧道不存在")
|
||||
}
|
||||
cfg, _ := store.TunnelConfig(&tunnel)
|
||||
mfaMode := service.StrOr(cfg, "mfa_mode", "off")
|
||||
if service.Bool(cfg, "require_mfa", false) && mfaMode == "off" {
|
||||
mfaMode = "required"
|
||||
}
|
||||
|
||||
// 未启用 MFA 时不要把密码末尾 6 位当成 OTP 截断,避免误伤含数字后缀的密码。
|
||||
password, response := passwordField, ""
|
||||
if mfaMode == "optional" || mfaMode == "required" {
|
||||
password, response = splitPasswordAndOTP(passwordField, challenge)
|
||||
} else if decoded := decodeSCRV1(passwordField); decoded.ok {
|
||||
password = decoded.password
|
||||
}
|
||||
|
||||
var user model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND username = ? AND enabled = ?", tunnelID, username, true).First(&user).Error; err != nil {
|
||||
return errors.New("拨号用户不存在或已禁用")
|
||||
}
|
||||
if err := verifyDialPassword(&user, password); err != nil {
|
||||
return err
|
||||
}
|
||||
return verifyDialMFA(&user, cfg, mfaMode, response)
|
||||
}
|
||||
|
||||
func verifyDialPassword(user *model.VPNUser, password string) error {
|
||||
if user.Source == "local" {
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return errors.New("密码错误")
|
||||
@@ -512,3 +929,331 @@ func Authenticate(tunnelID uint, username, password string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var mfaAuthMu sync.Mutex
|
||||
|
||||
func verifyDialMFA(user *model.VPNUser, tunnelCfg map[string]interface{}, mfaMode, response string) error {
|
||||
switch mfaMode {
|
||||
case "", "off":
|
||||
return nil
|
||||
case "optional":
|
||||
if !user.MFAEnabled {
|
||||
return nil
|
||||
}
|
||||
case "required":
|
||||
if !user.MFAEnabled {
|
||||
return errors.New("该隧道要求 MFA,请先在管理端绑定 Authenticator")
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
mfaAuthMu.Lock()
|
||||
defer mfaAuthMu.Unlock()
|
||||
// 重新加载用户以拿到最新锁定/计数器状态。
|
||||
if store.DB != nil {
|
||||
_ = store.DB.First(user, user.ID).Error
|
||||
}
|
||||
if user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil) {
|
||||
return fmt.Errorf("MFA 已锁定至 %s,请稍后再试或联系管理员解锁", user.MFALockedUntil.Local().Format("15:04:05"))
|
||||
}
|
||||
response = strings.TrimSpace(response)
|
||||
if response == "" {
|
||||
recordMFAFailure(user, tunnelCfg)
|
||||
return errors.New("需要 MFA 验证码(Google Authenticator 6 位,或 PIN+OTP)")
|
||||
}
|
||||
|
||||
// 1) 备用码(可含 PIN 前缀:PIN+BACKUP)
|
||||
otpPart, pinErr := extractOTPAfterPIN(user, response)
|
||||
if pinErr != nil {
|
||||
recordMFAFailure(user, tunnelCfg)
|
||||
return pinErr
|
||||
}
|
||||
if consumeBackupCode(user, otpPart) {
|
||||
clearMFAFailure(user)
|
||||
now := time.Now()
|
||||
user.MFALastUsedAt = &now
|
||||
_ = store.DB.Model(user).Updates(map[string]interface{}{
|
||||
"mfa_fail_count": 0, "mfa_locked_until": nil, "mfa_last_used_at": now,
|
||||
}).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2) TOTP 校验(sha1/base32/6/30s + slop 时钟偏差窗口)
|
||||
if user.MFASecret == "" {
|
||||
recordMFAFailure(user, tunnelCfg)
|
||||
return errors.New("MFA 密钥无效")
|
||||
}
|
||||
secret, err := store.DecryptSecret(user.MFASecret)
|
||||
if err != nil || secret == "" {
|
||||
recordMFAFailure(user, tunnelCfg)
|
||||
return errors.New("MFA 密钥无效")
|
||||
}
|
||||
slop := int(service.Num(tunnelCfg, "otp_slop", otpDefaultSlop))
|
||||
if slop < 0 {
|
||||
slop = 0
|
||||
}
|
||||
skew := uint(slop / otpPeriod)
|
||||
if skew < 1 && slop > 0 {
|
||||
skew = 1
|
||||
}
|
||||
ok, counter := validateTOTPSkew(otpPart, secret, skew)
|
||||
if !ok {
|
||||
recordMFAFailure(user, tunnelCfg)
|
||||
return errors.New("MFA 验证码错误")
|
||||
}
|
||||
if service.Bool(tunnelCfg, "otp_disallow_reuse", true) && user.MFALastCounter != 0 && counter == user.MFALastCounter {
|
||||
recordMFAFailure(user, tunnelCfg)
|
||||
return errors.New("该验证码已使用,请等待下一个 30 秒周期")
|
||||
}
|
||||
now := time.Now()
|
||||
user.MFALastCounter = counter
|
||||
user.MFALastUsedAt = &now
|
||||
user.MFAFailCount = 0
|
||||
user.MFALockedUntil = nil
|
||||
_ = store.DB.Model(user).Updates(map[string]interface{}{
|
||||
"mfa_last_counter": counter,
|
||||
"mfa_last_used_at": now,
|
||||
"mfa_fail_count": 0,
|
||||
"mfa_locked_until": nil,
|
||||
}).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractOTPAfterPIN 从响应中提取 OTP:response = [pin][otp] 拼接格式。
|
||||
// 若用户设置了 PIN,则必须作为前缀;OTP 为剩余部分(6 位或备用码)。
|
||||
func extractOTPAfterPIN(user *model.VPNUser, response string) (string, error) {
|
||||
if !user.MFAHasPin || user.MFAPin == "" {
|
||||
return response, nil
|
||||
}
|
||||
pin, err := store.DecryptSecret(user.MFAPin)
|
||||
if err != nil || pin == "" {
|
||||
return "", errors.New("MFA PIN 配置无效")
|
||||
}
|
||||
if !strings.HasPrefix(response, pin) {
|
||||
return "", errors.New("MFA PIN 错误")
|
||||
}
|
||||
otp := strings.TrimPrefix(response, pin)
|
||||
if otp == "" {
|
||||
return "", errors.New("请在 PIN 后输入 6 位动态码")
|
||||
}
|
||||
return otp, nil
|
||||
}
|
||||
|
||||
func validateTOTPSkew(code, secret string, skew uint) (bool, int64) {
|
||||
code = strings.TrimSpace(code)
|
||||
if !isSixDigits(code) {
|
||||
return false, 0
|
||||
}
|
||||
now := time.Now()
|
||||
opts := totp.ValidateOpts{
|
||||
Period: otpPeriod,
|
||||
Skew: skew,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
}
|
||||
ok, err := totp.ValidateCustom(code, secret, now, opts)
|
||||
if err != nil || !ok {
|
||||
return false, 0
|
||||
}
|
||||
// 定位实际命中的时间窗计数器(用于防重放)。
|
||||
period := int64(otpPeriod)
|
||||
base := now.Unix() / period
|
||||
for i := -int(skew); i <= int(skew); i++ {
|
||||
c := base + int64(i)
|
||||
t := time.Unix(c*period, 0)
|
||||
if valid, _ := totp.ValidateCustom(code, secret, t, totp.ValidateOpts{
|
||||
Period: otpPeriod, Skew: 0, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
|
||||
}); valid {
|
||||
return true, c
|
||||
}
|
||||
}
|
||||
return true, base
|
||||
}
|
||||
|
||||
func recordMFAFailure(user *model.VPNUser, tunnelCfg map[string]interface{}) {
|
||||
if store.DB == nil || user == nil {
|
||||
return
|
||||
}
|
||||
maxFail := int(service.Num(tunnelCfg, "otp_max_failures", 5))
|
||||
lockSec := int(service.Num(tunnelCfg, "otp_lockout_sec", 300))
|
||||
user.MFAFailCount++
|
||||
updates := map[string]interface{}{"mfa_fail_count": user.MFAFailCount}
|
||||
if maxFail > 0 && user.MFAFailCount >= maxFail && lockSec > 0 {
|
||||
until := time.Now().Add(time.Duration(lockSec) * time.Second)
|
||||
user.MFALockedUntil = &until
|
||||
updates["mfa_locked_until"] = until
|
||||
}
|
||||
_ = store.DB.Model(user).Updates(updates).Error
|
||||
}
|
||||
|
||||
func clearMFAFailure(user *model.VPNUser) {
|
||||
user.MFAFailCount = 0
|
||||
user.MFALockedUntil = nil
|
||||
}
|
||||
|
||||
// splitPasswordAndOTP 解析 OpenVPN 密码字段中的 MFA。
|
||||
// password_is_cr 模式:SCRV1 / 第三行 challenge;否则密码后缀 6 位 OTP。
|
||||
func splitPasswordAndOTP(passwordField, challenge string) (password, response string) {
|
||||
if decoded := decodeSCRV1(passwordField); decoded.ok {
|
||||
return decoded.password, decoded.otp
|
||||
}
|
||||
if challenge != "" {
|
||||
return passwordField, challenge
|
||||
}
|
||||
// 无 challenge:密码 + 6 位 OTP 直接拼接
|
||||
if len(passwordField) > 6 {
|
||||
suffix := passwordField[len(passwordField)-6:]
|
||||
if isSixDigits(suffix) {
|
||||
return passwordField[:len(passwordField)-6], suffix
|
||||
}
|
||||
}
|
||||
return passwordField, ""
|
||||
}
|
||||
|
||||
type scrv1 struct {
|
||||
password, otp string
|
||||
ok bool
|
||||
}
|
||||
|
||||
func decodeSCRV1(raw string) scrv1 {
|
||||
// SCRV1:<base64 password>:<base64 response>
|
||||
if !strings.HasPrefix(raw, "SCRV1:") {
|
||||
return scrv1{}
|
||||
}
|
||||
parts := strings.SplitN(raw, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
return scrv1{}
|
||||
}
|
||||
pw, err1 := base64.StdEncoding.DecodeString(parts[1])
|
||||
otp, err2 := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err1 != nil || err2 != nil {
|
||||
pw, err1 = base64.RawStdEncoding.DecodeString(parts[1])
|
||||
otp, err2 = base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err1 != nil || err2 != nil {
|
||||
return scrv1{}
|
||||
}
|
||||
}
|
||||
return scrv1{password: string(pw), otp: string(otp), ok: true}
|
||||
}
|
||||
|
||||
func isSixDigits(s string) bool {
|
||||
if len(s) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func consumeBackupCode(user *model.VPNUser, code string) bool {
|
||||
if user.MFABackupCodes == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
enc, err := store.DecryptSecret(user.MFABackupCodes)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var codes []string
|
||||
if json.Unmarshal([]byte(enc), &codes) != nil {
|
||||
return false
|
||||
}
|
||||
want := strings.ToUpper(strings.TrimSpace(code))
|
||||
next := make([]string, 0, len(codes))
|
||||
found := false
|
||||
for _, c := range codes {
|
||||
if !found && strings.ToUpper(strings.TrimSpace(c)) == want {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
next = append(next, c)
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
raw, _ := json.Marshal(next)
|
||||
encNext, err := store.EncryptSecret(string(raw))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
user.MFABackupCodes = encNext
|
||||
_ = store.DB.Model(user).Update("mfa_backup_codes", encNext).Error
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateOTPSecretsLine 导出标准 otp-secrets 格式密钥行(用于备份与迁移)。
|
||||
// 格式: user server type:hash:encoding:key:pin:udid client
|
||||
func GenerateOTPSecretsLine(user *model.VPNUser) (string, error) {
|
||||
if !user.MFAEnabled || user.MFASecret == "" {
|
||||
return "", errors.New("用户未启用 MFA")
|
||||
}
|
||||
secret, err := store.DecryptSecret(user.MFASecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pin := ""
|
||||
if user.MFAHasPin && user.MFAPin != "" {
|
||||
pin, _ = store.DecryptSecret(user.MFAPin)
|
||||
}
|
||||
// totp:sha1:base32:KEY:PIN:xxx
|
||||
return fmt.Sprintf("%s otp totp:sha1:base32:%s:%s:xxx *", user.Username, secret, pin), nil
|
||||
}
|
||||
|
||||
// SetUserMFAPin 设置或清除静态 PIN(连接时第二因子 PIN+OTP)。
|
||||
func SetUserMFAPin(user *model.VPNUser, pin string) error {
|
||||
if pin == "" {
|
||||
user.MFAPin = ""
|
||||
user.MFAHasPin = false
|
||||
return store.DB.Model(user).Updates(map[string]interface{}{"mfa_pin": "", "mfa_has_pin": false}).Error
|
||||
}
|
||||
if len(pin) < 4 || len(pin) > 32 {
|
||||
return errors.New("PIN 长度需 4-32 位")
|
||||
}
|
||||
if strings.ContainsAny(pin, " \t\r\n") {
|
||||
return errors.New("PIN 不能包含空白字符")
|
||||
}
|
||||
enc, err := store.EncryptSecret(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.MFAPin = enc
|
||||
user.MFAHasPin = true
|
||||
return store.DB.Model(user).Updates(map[string]interface{}{"mfa_pin": enc, "mfa_has_pin": true}).Error
|
||||
}
|
||||
|
||||
// UnlockUserMFA 清除失败计数与锁定(OTP Manager)。
|
||||
func UnlockUserMFA(user *model.VPNUser) error {
|
||||
user.MFAFailCount = 0
|
||||
user.MFALockedUntil = nil
|
||||
return store.DB.Model(user).Updates(map[string]interface{}{
|
||||
"mfa_fail_count": 0, "mfa_locked_until": nil,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// RegenerateBackupCodes 重新生成一次性备用码。
|
||||
func RegenerateBackupCodes(user *model.VPNUser) ([]string, error) {
|
||||
if !user.MFAEnabled {
|
||||
return nil, errors.New("请先启用 MFA")
|
||||
}
|
||||
codes := make([]string, 10)
|
||||
for i := range codes {
|
||||
raw := make([]byte, 5)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes[i] = fmt.Sprintf("%X", raw)
|
||||
}
|
||||
raw, _ := json.Marshal(codes)
|
||||
enc, err := store.EncryptSecret(string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.MFABackupCodes = enc
|
||||
if err := store.DB.Model(user).Update("mfa_backup_codes", enc).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestValidateOpenVPNModes(t *testing.T) {
|
||||
@@ -16,6 +22,110 @@ func TestValidateOpenVPNModes(t *testing.T) {
|
||||
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid auth mode error")
|
||||
}
|
||||
// 前端历史值 tcp 应归一为 tcp-server
|
||||
cfg2, err := New().Validate(map[string]interface{}{"proto": "tcp", "subnet": "10.8.0.0 255.255.255.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg2["proto"] != "tcp-server" {
|
||||
t.Fatalf("tcp not normalized: %#v", cfg2)
|
||||
}
|
||||
cfg3, err := New().Validate(map[string]interface{}{
|
||||
"subnet": "10.8.0.0 255.255.255.0", "push_routes": "192.168.1.0/24",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg3["redirect_gateway"] != false {
|
||||
t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3)
|
||||
}
|
||||
// MFA 仅允许密码相关模式
|
||||
if _, err := New().Validate(map[string]interface{}{
|
||||
"auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0",
|
||||
}); err == nil {
|
||||
t.Fatal("expected MFA + certificate-only to fail")
|
||||
}
|
||||
cfg4, err := New().Validate(map[string]interface{}{
|
||||
"auth_mode": "password", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0", "proto": "tcp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg4["proto"] != "tcp-server" || cfg4["mfa_mode"] != "required" {
|
||||
t.Fatalf("mfa/tcp normalize: %#v", cfg4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPasswordAndOTP(t *testing.T) {
|
||||
pw, otp := splitPasswordAndOTP("secretpass123456", "")
|
||||
if pw != "secretpass" || otp != "123456" {
|
||||
t.Fatalf("suffix: %q %q", pw, otp)
|
||||
}
|
||||
scrv := "SCRV1:" + base64.StdEncoding.EncodeToString([]byte("mypass")) + ":" + base64.StdEncoding.EncodeToString([]byte("654321"))
|
||||
pw, otp = splitPasswordAndOTP(scrv, "")
|
||||
if pw != "mypass" || otp != "654321" {
|
||||
t.Fatalf("scrv1: %q %q", pw, otp)
|
||||
}
|
||||
pw, otp = splitPasswordAndOTP("onlypass", "111222")
|
||||
if pw != "onlypass" || otp != "111222" {
|
||||
t.Fatalf("challenge line: %q %q", pw, otp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientProto(t *testing.T) {
|
||||
if clientProto("tcp-server") != "tcp-client" || clientProto("udp") != "udp" || clientProto("tcp") != "tcp-client" {
|
||||
t.Fatal("clientProto mapping failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigIncludesProductionTunings(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{
|
||||
"auth_mode": "password", "proto": "tcp", "subnet": "10.8.0.0 255.255.240.0",
|
||||
"max_clients": 1000, "mfa_mode": "off",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["proto"] != "tcp-server" {
|
||||
t.Fatalf("proto: %#v", cfg["proto"])
|
||||
}
|
||||
tunn := &model.Tunnel{ID: 1, Protocol: "openvpn"}
|
||||
raw := New().serverConfig(tunn, cfg)
|
||||
for _, want := range []string{
|
||||
"proto tcp-server",
|
||||
"tun-mtu 1500",
|
||||
"mssfix 1450",
|
||||
"keepalive 5 30",
|
||||
"reneg-sec 0",
|
||||
"verify-client-cert none",
|
||||
"username-as-common-name",
|
||||
"max-clients 1000",
|
||||
"duplicate-cn",
|
||||
"via-file",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Fatalf("server.conf missing %q\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(raw, "via-env") {
|
||||
t.Fatal("should use via-file not via-env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTOTPSkew(t *testing.T) {
|
||||
// 使用固定 secret 生成当前码并校验
|
||||
secret := "JBSWY3DPEHPK3PXP" // "Hello!" base32, common test secret
|
||||
code, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, counter := validateTOTPSkew(code, secret, 1)
|
||||
if !ok || counter == 0 {
|
||||
t.Fatalf("expected valid totp, ok=%v counter=%d", ok, counter)
|
||||
}
|
||||
if ok2, _ := validateTOTPSkew("000000", secret, 1); ok2 {
|
||||
t.Fatal("expected invalid code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateCNIsSafeAndStable(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,11 @@ package wireguard
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -140,8 +143,16 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if egress := service.Str(out, "egress_interface"); egress != "" && !ifacePattern.MatchString(egress) {
|
||||
return nil, fmt.Errorf("egress_interface 包含非法字符")
|
||||
}
|
||||
// 启用 NAT 但未指定出口时,自动探测默认路由网卡;探测失败再要求手填。
|
||||
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
|
||||
return nil, fmt.Errorf("启用 NAT 时必须填写 egress_interface")
|
||||
iface, err := service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("启用 NAT 时无法自动探测出口网卡,请填写 egress_interface: %w", err)
|
||||
}
|
||||
out["egress_interface"] = iface
|
||||
}
|
||||
if mtu := int(service.Num(out, "mtu", 0)); mtu != 0 && (mtu < 576 || mtu > 9000) {
|
||||
return nil, fmt.Errorf("mtu 必须在 576-9000 之间")
|
||||
}
|
||||
if _, supplied := out["peers"]; supplied {
|
||||
peers, err := parsePeers(out)
|
||||
@@ -286,18 +297,22 @@ func generateClientConfig(cfg map[string]interface{}, peers []peer, peerSecrets
|
||||
if allowed == "" {
|
||||
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
mtuLine := ""
|
||||
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
|
||||
mtuLine = fmt.Sprintf("MTU = %d\n", mtu)
|
||||
}
|
||||
return fmt.Sprintf(`[Interface]
|
||||
PrivateKey = %s
|
||||
Address = %s
|
||||
DNS = %s
|
||||
|
||||
%s
|
||||
[Peer]
|
||||
PublicKey = %s
|
||||
PresharedKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = %d
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
}
|
||||
if requestedID != "" {
|
||||
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
|
||||
@@ -354,7 +369,11 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
for _, p := range peers {
|
||||
byKey[p.PublicKey] = p
|
||||
}
|
||||
// 内核计数器在接口重启后归零;用 traffic-state 做增量,再叠到库中的累计值。
|
||||
prev := loadTrafficState(t)
|
||||
next := map[string][2]int64{}
|
||||
st := service.NowStatus(true, "running", "WireGuard 接口运行中")
|
||||
var deltaRx, deltaTx int64
|
||||
connected := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
@@ -374,13 +393,25 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if alive {
|
||||
connected++
|
||||
}
|
||||
st.RxBytes += rx
|
||||
st.TxBytes += tx
|
||||
old := prev[fields[0]]
|
||||
dRx, dTx := rx-old[0], tx-old[1]
|
||||
if dRx < 0 {
|
||||
dRx = rx
|
||||
}
|
||||
if dTx < 0 {
|
||||
dTx = tx
|
||||
}
|
||||
deltaRx += dRx
|
||||
deltaTx += dTx
|
||||
next[fields[0]] = [2]int64{rx, tx}
|
||||
st.Peers = append(st.Peers, core.PeerStatus{
|
||||
ID: p.ID, Name: p.Name, PublicKey: fields[0], Endpoint: fields[2], AllowedIPs: fields[3],
|
||||
LatestHandshakeAt: handshake, Connected: alive, RxBytes: rx, TxBytes: tx,
|
||||
})
|
||||
}
|
||||
saveTrafficState(t, next)
|
||||
st.RxBytes = t.RxBytes + deltaRx
|
||||
st.TxBytes = t.TxBytes + deltaTx
|
||||
sort.Slice(st.Peers, func(i, j int) bool { return st.Peers[i].ID < st.Peers[j].ID })
|
||||
st.Detail = fmt.Sprintf("WireGuard 接口 %s 运行中,%d/%d 个 Peer 最近已握手", interfaceName(t), connected, len(st.Peers))
|
||||
return st, nil
|
||||
@@ -388,6 +419,117 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// AddPeer appends a managed or external peer and rewrites configuration.
|
||||
func (s *Service) AddPeer(t *model.Tunnel, input map[string]interface{}) (peer, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return peer{}, err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return peer{}, err
|
||||
}
|
||||
p := peer{
|
||||
ID: service.Str(input, "id"),
|
||||
Name: service.Str(input, "name"),
|
||||
PublicKey: service.Str(input, "public_key"),
|
||||
ClientAddress: service.Str(input, "client_address"),
|
||||
AllowedIPs: service.Str(input, "allowed_ips"),
|
||||
Enabled: true,
|
||||
Managed: service.Str(input, "public_key") == "",
|
||||
}
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("client-%d", len(peers)+1)
|
||||
}
|
||||
if p.Name == "" {
|
||||
p.Name = p.ID
|
||||
}
|
||||
if p.ClientAddress == "" {
|
||||
return peer{}, fmt.Errorf("client_address 必填")
|
||||
}
|
||||
peers = append(peers, p)
|
||||
cfg["peers"] = peersToConfig(peers)
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
if err := s.Create(t); err != nil {
|
||||
return peer{}, err
|
||||
}
|
||||
// Return the persisted peer (with generated public key when managed).
|
||||
cfg, _ = store.TunnelConfig(t)
|
||||
peers, _ = parsePeers(cfg)
|
||||
for _, item := range peers {
|
||||
if item.ID == p.ID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// RemovePeer deletes one peer by id and rewrites configuration.
|
||||
func (s *Service) RemovePeer(t *model.Tunnel, peerID string) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next := make([]peer, 0, len(peers))
|
||||
found := false
|
||||
for _, p := range peers {
|
||||
if p.ID == peerID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
next = append(next, p)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Peer 不存在: %s", peerID)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return fmt.Errorf("至少保留一个 Peer")
|
||||
}
|
||||
cfg["peers"] = peersToConfig(next)
|
||||
// Drop removed peer secrets so keys are not retained after revocation.
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
if raw, ok := secrets["peer_secrets"].(map[string]interface{}); ok {
|
||||
delete(raw, peerID)
|
||||
secrets["peer_secrets"] = raw
|
||||
_ = service.SaveTunnelConfig(t, cfg, secrets)
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
return s.Create(t)
|
||||
}
|
||||
|
||||
func loadTrafficState(t *model.Tunnel) map[string][2]int64 {
|
||||
out := map[string][2]int64{}
|
||||
raw, err := os.ReadFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var m map[string][]int64
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return out
|
||||
}
|
||||
for k, v := range m {
|
||||
if len(v) >= 2 {
|
||||
out[k] = [2]int64{v[0], v[1]}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func saveTrafficState(t *model.Tunnel, state map[string][2]int64) {
|
||||
m := make(map[string][]int64, len(state))
|
||||
for k, v := range state {
|
||||
m[k] = []int64{v[0], v[1]}
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"), raw, 0o600)
|
||||
}
|
||||
|
||||
// interfaceName is deterministic, <= IFNAMSIZ-1 (15) and does not depend on
|
||||
// the user supplied tunnel name. This prevents cross-tunnel interface clashes.
|
||||
func interfaceName(t *model.Tunnel) string { return "wg" + strconv.FormatUint(uint64(t.ID), 36) }
|
||||
@@ -640,6 +782,9 @@ func peerSecretsToConfig(secrets map[string]peerSecret) map[string]interface{} {
|
||||
func renderServerConfig(cfg map[string]interface{}, serverPrivate string, peers []peer, secrets map[string]peerSecret) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s\nListenPort = %d\n", serverPrivate, service.Str(cfg, "address"), int(service.Num(cfg, "listen_port", defaultPort)))
|
||||
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
|
||||
fmt.Fprintf(&b, "MTU = %d\n", mtu)
|
||||
}
|
||||
if service.Bool(cfg, "enable_nat", false) {
|
||||
egress := service.Str(cfg, "egress_interface")
|
||||
fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n", egress)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -67,8 +68,11 @@ func TestBuildPeersKeepsExternalPeerPrivateKeyOutOfPlatform(t *testing.T) {
|
||||
|
||||
func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) {
|
||||
s := New()
|
||||
if _, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
|
||||
t.Fatal("NAT without explicit egress interface was accepted")
|
||||
// enable_nat without egress:若本机无默认路由则失败;有默认路由则自动填 egress_interface。
|
||||
if cfg, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
|
||||
if service.Str(cfg, "egress_interface") == "" {
|
||||
t.Fatal("NAT auto-detect succeeded but egress_interface is empty")
|
||||
}
|
||||
}
|
||||
_, err := s.Validate(map[string]interface{}{
|
||||
"address": "10.8.0.1/24",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Package sessionmgr 管理 VPN 在线会话:同步运行时 peers → VPNSession 表,
|
||||
// 提供在线列表查询和踢下线能力。跨协议统一(WireGuard peers / OpenVPN clients)。
|
||||
package sessionmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// ClientInfo 协议层上报的在线客户端信息(统一格式)
|
||||
type ClientInfo struct {
|
||||
Username string // 用户名或证书 CN
|
||||
RealAddress string // 客户端公网地址 IP:Port
|
||||
VirtualAddress string // 分配的 VPN IP
|
||||
RxBytes int64
|
||||
TxBytes int64
|
||||
}
|
||||
|
||||
// SyncSessions 将运行时客户端列表同步到 VPNSession 表。
|
||||
// 新连接 → 创建 active 记录 + 写 ConnectionLog(connect);
|
||||
// 已不在列表中的 active 会话 → 标记 disconnected + 写 ConnectionLog(disconnect)。
|
||||
func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
|
||||
if store.DB == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
// 1. 收集当前在线的标识 key(username|realAddress)
|
||||
currentKeys := map[string]bool{}
|
||||
for _, c := range clients {
|
||||
key := c.Username + "|" + c.RealAddress
|
||||
currentKeys[key] = true
|
||||
|
||||
// 查是否已有 active 会话
|
||||
var existing model.VPNSession
|
||||
err := store.DB.Where("tunnel_id = ? AND username = ? AND real_address = ? AND status = ?",
|
||||
tunnelID, c.Username, c.RealAddress, "active").First(&existing).Error
|
||||
|
||||
if err == nil {
|
||||
// 已有会话 → 更新流量
|
||||
store.DB.Model(&existing).Updates(map[string]interface{}{
|
||||
"rx_bytes": c.RxBytes, "tx_bytes": c.TxBytes,
|
||||
})
|
||||
} else {
|
||||
// 新连接 → 创建会话 + 审计日志
|
||||
session := model.VPNSession{
|
||||
TunnelID: tunnelID,
|
||||
Username: c.Username,
|
||||
Protocol: protocol,
|
||||
RealAddress: c.RealAddress,
|
||||
VirtualAddress: c.VirtualAddress,
|
||||
ConnectedAt: now,
|
||||
RxBytes: c.RxBytes,
|
||||
TxBytes: c.TxBytes,
|
||||
Status: "active",
|
||||
}
|
||||
// 尝试关联 VPNUser
|
||||
var vu model.VPNUser
|
||||
if store.DB.Where("tunnel_id = ? AND username = ?", tunnelID, c.Username).First(&vu).Error == nil {
|
||||
session.VPNUserID = vu.ID
|
||||
}
|
||||
store.DB.Create(&session)
|
||||
LogConnection(tunnelID, c.Username, "connect", c.RealAddress, "")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 标记已不在列表中的 active 会话为 disconnected
|
||||
var activeSessions []model.VPNSession
|
||||
store.DB.Where("tunnel_id = ? AND status = ?", tunnelID, "active").Find(&activeSessions)
|
||||
for _, s := range activeSessions {
|
||||
key := s.Username + "|" + s.RealAddress
|
||||
if !currentKeys[key] {
|
||||
store.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"status": "disconnected",
|
||||
"disconnected_at": now,
|
||||
})
|
||||
duration := now.Sub(s.ConnectedAt).Round(time.Second)
|
||||
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress,
|
||||
fmt.Sprintf("时长 %s, ↓%s ↑%s", duration, fmtBytes(s.RxBytes), fmtBytes(s.TxBytes)))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果 clients 为空(隧道无在线用户),标记该隧道所有 active 会话为 disconnected
|
||||
if len(clients) == 0 {
|
||||
for _, s := range activeSessions {
|
||||
store.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"status": "disconnected",
|
||||
"disconnected_at": now,
|
||||
})
|
||||
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress, "隧道无在线用户")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SyncFromPeers 从 core.Status.Peers 同步(WireGuard 用)
|
||||
func SyncFromPeers(tunnelID uint, protocol string, peers []core.PeerStatus) {
|
||||
clients := make([]ClientInfo, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
if !p.Connected {
|
||||
continue
|
||||
}
|
||||
clients = append(clients, ClientInfo{
|
||||
Username: p.Name, // WG peer 名称
|
||||
RealAddress: p.Endpoint,
|
||||
RxBytes: p.RxBytes,
|
||||
TxBytes: p.TxBytes,
|
||||
})
|
||||
}
|
||||
SyncSessions(tunnelID, protocol, clients)
|
||||
}
|
||||
|
||||
// ListActive 查询所有在线会话
|
||||
func ListActive() []model.VPNSession {
|
||||
var sessions []model.VPNSession
|
||||
store.DB.Where("status = ?", "active").Order("connected_at desc").Find(&sessions)
|
||||
return sessions
|
||||
}
|
||||
|
||||
// CountActive 统计在线会话数
|
||||
func CountActive() int64 {
|
||||
var count int64
|
||||
store.DB.Model(&model.VPNSession{}).Where("status = ?", "active").Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// LogConnection 写连接审计日志
|
||||
func LogConnection(tunnelID uint, username, event, ip, detail string) {
|
||||
if store.DB == nil {
|
||||
return
|
||||
}
|
||||
store.DB.Create(&model.ConnectionLog{
|
||||
TunnelID: tunnelID,
|
||||
Username: username,
|
||||
Event: event,
|
||||
IPAddress: ip,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// ListLogs 查询连接审计日志
|
||||
func ListLogs(limit int) []model.ConnectionLog {
|
||||
var logs []model.ConnectionLog
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
store.DB.Order("id desc").Limit(limit).Find(&logs)
|
||||
return logs
|
||||
}
|
||||
|
||||
func fmtBytes(n int64) string {
|
||||
if n < 1024 {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
if n < 1024*1024 {
|
||||
return fmt.Sprintf("%.1fKB", float64(n)/1024)
|
||||
}
|
||||
if n < 1024*1024*1024 {
|
||||
return fmt.Sprintf("%.1fMB", float64(n)/(1024*1024))
|
||||
}
|
||||
return fmt.Sprintf("%.1fGB", float64(n)/(1024*1024*1024))
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package sshutil 提供 SSH 连接测试能力,用于远程节点凭证验证。
|
||||
// 借鉴 aiops pkg/remoteexec 的连接逻辑,精简为节点连通性测试专用。
|
||||
// 基于 golang.org/x/crypto/ssh,支持密码认证与私钥认证。
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Config SSH 连接测试配置
|
||||
type Config struct {
|
||||
Host string // 目标 IP 或域名
|
||||
Port int // SSH 端口(默认 22)
|
||||
User string // SSH 用户(默认 root)
|
||||
AuthType string // password / key
|
||||
Password string // 密码认证
|
||||
KeyData string // 私钥内容
|
||||
}
|
||||
|
||||
// Result 连接测试结果
|
||||
type Result struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"` // 成功时为 uname 输出,失败时为友好错误
|
||||
Output string `json:"output"` // 原始命令输出
|
||||
Took int64 `json:"took_ms"` // 耗时(毫秒)
|
||||
}
|
||||
|
||||
// TestConnection 测试 SSH 连接并执行探测命令,返回友好结果。
|
||||
// 探测命令:echo vpn-node-ok && uname -a(与 aiops build-node 一致)
|
||||
func TestConnection(cfg Config) Result {
|
||||
start := time.Now()
|
||||
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 22
|
||||
}
|
||||
if cfg.User == "" {
|
||||
cfg.User = "root"
|
||||
}
|
||||
|
||||
authMethods, err := buildAuth(cfg)
|
||||
if err != nil {
|
||||
return Result{Success: false, Message: err.Error(), Took: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: authMethods,
|
||||
Timeout: 10 * time.Second,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网管理工具,简化主机密钥校验
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
return Result{Success: false, Message: formatSSHError(err, addr), Took: time.Since(start).Milliseconds()}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 执行探测命令
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return Result{Success: false, Message: fmt.Sprintf("创建会话失败: %v", err), Took: time.Since(start).Milliseconds()}
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
out, err := session.CombinedOutput("echo vpn-node-ok && uname -a")
|
||||
output := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
return Result{Success: false, Message: fmt.Sprintf("命令执行失败: %v", err), Output: output, Took: time.Since(start).Milliseconds()}
|
||||
}
|
||||
|
||||
return Result{
|
||||
Success: true,
|
||||
Message: output,
|
||||
Output: output,
|
||||
Took: time.Since(start).Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
// buildAuth 根据认证方式构建 ssh.AuthMethod 列表。
|
||||
// 密钥认证解析失败时,若有密码则兜底用密码(aiops 模式)。
|
||||
func buildAuth(cfg Config) ([]ssh.AuthMethod, error) {
|
||||
switch cfg.AuthType {
|
||||
case "key":
|
||||
if cfg.KeyData == "" {
|
||||
return nil, fmt.Errorf("私钥认证模式但未提供私钥")
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey([]byte(cfg.KeyData))
|
||||
if err != nil {
|
||||
// 私钥解析失败,尝试密码兜底
|
||||
if cfg.Password != "" {
|
||||
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("私钥解析失败: %v", err)
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||
default: // password
|
||||
if cfg.Password == "" {
|
||||
return nil, fmt.Errorf("密码认证模式但未提供密码")
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// formatSSHError 将底层 SSH 错误映射为用户友好的中文提示
|
||||
func formatSSHError(err error, addr string) string {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline exceeded"):
|
||||
return fmt.Sprintf("连接超时:无法在 10 秒内连接到 %s,请检查地址/端口/防火墙", addr)
|
||||
case strings.Contains(msg, "connection refused"):
|
||||
return fmt.Sprintf("连接被拒绝:%s 端口未开放 SSH 服务", addr)
|
||||
case strings.Contains(msg, "no route to host"):
|
||||
return fmt.Sprintf("网络不可达:%s 地址不存在或路由不通", addr)
|
||||
case strings.Contains(msg, "authentication failed"), strings.Contains(msg, "unable to authenticate"):
|
||||
return "认证失败:用户名/密码/私钥不正确"
|
||||
case strings.Contains(msg, "handshake"):
|
||||
return fmt.Sprintf("SSH 握手失败:%s 可能不是标准 SSH 服务", addr)
|
||||
case strings.Contains(msg, "dns"), strings.Contains(msg, "no such host"):
|
||||
return fmt.Sprintf("域名解析失败:%s", addr)
|
||||
default:
|
||||
return fmt.Sprintf("连接失败:%s", msg)
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,21 @@ func Init(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "vpn-platform.db")), &gorm.Config{
|
||||
// WAL + busy_timeout:避免 monitor 周期写与 API 写并发时 database is locked
|
||||
dsn := filepath.Join(dataDir, "vpn-platform.db") +
|
||||
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}); err != nil {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1) // SQLite 单写者
|
||||
}
|
||||
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
||||
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
|
||||
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}); err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
@@ -45,12 +53,12 @@ func Init(dataDir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 默认管理员 admin / admin123(首次登录后请修改) // Logan.Li
|
||||
// 默认管理员 admin / admin123(首次登录后强制改密) // Logan.Li
|
||||
var count int64
|
||||
DB.Model(&model.User{}).Count(&count)
|
||||
if count == 0 {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
DB.Create(&model.User{Username: "admin", Password: string(hash), Role: "admin", Source: "local", Enabled: true})
|
||||
DB.Create(&model.User{Username: "admin", Password: string(hash), Role: "admin", Source: "local", Enabled: true, MustChangePassword: true})
|
||||
} else {
|
||||
// 兼容早期单管理员数据:管理员升级后保留完整管理权限。
|
||||
DB.Model(&model.User{}).Where("username = ? AND source = ?", "admin", "local").Update("role", "admin")
|
||||
@@ -59,7 +67,7 @@ func Init(dataDir string) error {
|
||||
var nodeCount int64
|
||||
DB.Model(&model.Node{}).Count(&nodeCount)
|
||||
if nodeCount == 0 {
|
||||
DB.Create(&model.Node{Name: "本机节点", Address: "127.0.0.1", Kind: "local", Remark: "平台所在服务器"})
|
||||
DB.Create(&model.Node{Name: "本机节点", Address: "127.0.0.1", Port: 22, Kind: "local", Status: "verified", Remark: "平台所在服务器"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package trafficmgr 提供流量配额检查、周期重置、异常速率检测和限速能力。
|
||||
// 由 monitor 每 15s 调用一次,在流量增量产生时同步配额并触发超额动作。
|
||||
package trafficmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/sessionmgr"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// 异常流量阈值:15s 内超过此字节数视为突发(约 50Mbps × 15s ≈ 93MB)
|
||||
const rateAlertThreshold = 90 * 1024 * 1024
|
||||
|
||||
// CheckQuota 检查指定隧道的流量配额,返回超额动作("" 表示未超额或无限额)
|
||||
func CheckQuota(tunnelID uint, deltaRx, deltaTx int64) string {
|
||||
if store.DB == nil {
|
||||
return ""
|
||||
}
|
||||
var policy model.TrafficPolicy
|
||||
if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error != nil {
|
||||
return "" // 无策略 = 不限
|
||||
}
|
||||
|
||||
// 周期重置
|
||||
ResetIfNeeded(&policy)
|
||||
|
||||
// 累加已用流量
|
||||
delta := deltaRx + deltaTx
|
||||
policy.UsedBytes += delta
|
||||
store.DB.Model(&policy).Update("used_bytes", policy.UsedBytes)
|
||||
|
||||
// 无配额限制
|
||||
if policy.QuotaBytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 检查是否超额
|
||||
if policy.UsedBytes >= policy.QuotaBytes {
|
||||
return policy.ActionOnExceed // warn / block / throttle
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResetIfNeeded 检查是否到达重置周期,到期则清零已用流量
|
||||
func ResetIfNeeded(policy *model.TrafficPolicy) {
|
||||
if policy.ResetCycle == "none" || policy.ResetCycle == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
needReset := false
|
||||
switch policy.ResetCycle {
|
||||
case "daily":
|
||||
needReset = now.Sub(policy.LastResetAt) >= 24*time.Hour
|
||||
case "monthly":
|
||||
needReset = now.Sub(policy.LastResetAt) >= 30*24*time.Hour
|
||||
}
|
||||
if needReset {
|
||||
policy.UsedBytes = 0
|
||||
policy.LastResetAt = now
|
||||
store.DB.Model(policy).Updates(map[string]interface{}{
|
||||
"used_bytes": 0,
|
||||
"last_reset_at": now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CheckRateAbnormal 检查流量速率是否异常(突发流量告警)
|
||||
func CheckRateAbnormal(tunnelID uint, deltaRx, deltaTx int64, interval time.Duration) bool {
|
||||
total := deltaRx + deltaTx
|
||||
if total <= 0 {
|
||||
return false
|
||||
}
|
||||
ratePerSec := float64(total) / interval.Seconds()
|
||||
// 超过阈值(约 50Mbps)
|
||||
if total > rateAlertThreshold {
|
||||
sessionmgr.LogConnection(tunnelID, "*", "rate_alert", "",
|
||||
fmt.Sprintf("突发流量 %.1f MB(%.0f Mbps),15s 内增量异常", float64(total)/(1024*1024), ratePerSec*8/(1024*1024)))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleExceed 处理超额动作
|
||||
func HandleExceed(tunnelID uint, action string, tunnelName string) {
|
||||
switch action {
|
||||
case "warn":
|
||||
sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "",
|
||||
fmt.Sprintf("隧道「%s」流量配额已用尽(仅告警)", tunnelName))
|
||||
case "block":
|
||||
sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "",
|
||||
fmt.Sprintf("隧道「%s」流量配额已用尽,自动停止", tunnelName))
|
||||
// 由调用方执行 Stop(避免循环依赖,只返回信号)
|
||||
case "throttle":
|
||||
// 降速到 1Mbps
|
||||
var policy model.TrafficPolicy
|
||||
if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error == nil {
|
||||
ApplyRateLimit(tunnelID, 1)
|
||||
sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "",
|
||||
fmt.Sprintf("隧道「%s」流量配额已用尽,限速至 1 Mbps", tunnelName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRateLimit 使用 tc 设置隧道接口限速(需 root + tc 命令)
|
||||
func ApplyRateLimit(tunnelID uint, mbps int) {
|
||||
if mbps <= 0 {
|
||||
RemoveRateLimit(tunnelID)
|
||||
return
|
||||
}
|
||||
// tc 规则基于网络接口名,这里仅做记录;实际接口名需从隧道配置中获取
|
||||
// 完整实现需在 tunnel start 时获取 iface name 并传入
|
||||
if _, err := exec.LookPath("tc"); err != nil {
|
||||
return // tc 不存在则跳过
|
||||
}
|
||||
// 限速规则模板(需要接口名,此处占位)
|
||||
}
|
||||
|
||||
// RemoveRateLimit 清除限速规则
|
||||
func RemoveRateLimit(tunnelID uint) {
|
||||
if _, err := exec.LookPath("tc"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetPolicy 获取隧道的流量策略(不存在则返回默认空策略)
|
||||
func GetPolicy(tunnelID uint) model.TrafficPolicy {
|
||||
var policy model.TrafficPolicy
|
||||
if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error != nil {
|
||||
return model.TrafficPolicy{TunnelID: tunnelID, ActionOnExceed: "block", ResetCycle: "none"}
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
// SavePolicy 保存流量策略(upsert)
|
||||
func SavePolicy(policy *model.TrafficPolicy) error {
|
||||
if policy.TunnelID == 0 {
|
||||
return fmt.Errorf("tunnel_id 必填")
|
||||
}
|
||||
// 检查是否已存在
|
||||
var existing model.TrafficPolicy
|
||||
if store.DB.Where("tunnel_id = ?", policy.TunnelID).First(&existing).Error == nil {
|
||||
// 更新(保留 UsedBytes 和 LastResetAt)
|
||||
existing.QuotaBytes = policy.QuotaBytes
|
||||
existing.RateLimitMbps = policy.RateLimitMbps
|
||||
existing.ResetCycle = policy.ResetCycle
|
||||
existing.ActionOnExceed = policy.ActionOnExceed
|
||||
if existing.LastResetAt.IsZero() {
|
||||
existing.LastResetAt = time.Now()
|
||||
}
|
||||
return store.DB.Save(&existing).Error
|
||||
}
|
||||
// 新建
|
||||
policy.LastResetAt = time.Now()
|
||||
return store.DB.Create(policy).Error
|
||||
}
|
||||
|
||||
// ListAlerts 查询异常流量告警(rate_alert + quota_exceed)
|
||||
func ListAlerts(limit int) []model.ConnectionLog {
|
||||
var logs []model.ConnectionLog
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
events := []string{"rate_alert", "quota_exceed"}
|
||||
store.DB.Where("event IN ?", events).Order("id desc").Limit(limit).Find(&logs)
|
||||
return logs
|
||||
}
|
||||
|
||||
// QuotaPercent 计算配额使用百分比
|
||||
func QuotaPercent(policy *model.TrafficPolicy) int {
|
||||
if policy.QuotaBytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
pct := int(policy.UsedBytes * 100 / policy.QuotaBytes)
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// FormatBytes 格式化字节数
|
||||
func FormatBytes(n int64) string {
|
||||
if n < 1024 {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
if n < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
}
|
||||
if n < 1024*1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||
}
|
||||
return fmt.Sprintf("%.1f GB", float64(n)/(1024*1024*1024))
|
||||
}
|
||||
|
||||
// SplitEndpoint 从 "IP:Port" 中提取 IP
|
||||
func SplitEndpoint(addr string) string {
|
||||
if i := strings.LastIndex(addr, ":"); i > 0 {
|
||||
return addr[:i]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/service/custom"
|
||||
"vpn-platform/backend/internal/service/frp"
|
||||
"vpn-platform/backend/internal/service/ikev2"
|
||||
"vpn-platform/backend/internal/service/ipsec"
|
||||
"vpn-platform/backend/internal/service/l2tp"
|
||||
"vpn-platform/backend/internal/service/openvpn"
|
||||
@@ -51,6 +52,7 @@ func main() {
|
||||
mgr := core.NewManager()
|
||||
mgr.Register(wireguard.New())
|
||||
mgr.Register(ipsec.New())
|
||||
mgr.Register(ikev2.New())
|
||||
mgr.Register(l2tp.New())
|
||||
mgr.Register(frp.New())
|
||||
mgr.Register(openvpn.New())
|
||||
|
||||
+25
-3
@@ -1,21 +1,38 @@
|
||||
import axios from 'axios'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 极简全局状态
|
||||
// 极简全局状态(含角色与强制改密,对齐 Kimi 生产加固)
|
||||
export const auth = reactive({
|
||||
token: localStorage.getItem('vpn_token') || '',
|
||||
username: localStorage.getItem('vpn_user') || '',
|
||||
login(token, username) {
|
||||
role: localStorage.getItem('vpn_role') || 'viewer',
|
||||
mustChange: localStorage.getItem('vpn_must_change') === '1',
|
||||
login(token, username, role, mustChange) {
|
||||
this.token = token
|
||||
this.username = username
|
||||
this.role = role || 'viewer'
|
||||
this.mustChange = !!mustChange
|
||||
localStorage.setItem('vpn_token', token)
|
||||
localStorage.setItem('vpn_user', username)
|
||||
localStorage.setItem('vpn_role', this.role)
|
||||
localStorage.setItem('vpn_must_change', this.mustChange ? '1' : '0')
|
||||
},
|
||||
clearMustChange() {
|
||||
this.mustChange = false
|
||||
localStorage.setItem('vpn_must_change', '0')
|
||||
},
|
||||
isAdmin() { return this.role === 'admin' },
|
||||
canManage() { return this.role === 'admin' || this.role === 'operator' },
|
||||
logout() {
|
||||
if (this.token) api.post('/logout').catch(() => {})
|
||||
this.token = ''
|
||||
this.username = ''
|
||||
this.role = 'viewer'
|
||||
this.mustChange = false
|
||||
localStorage.removeItem('vpn_token')
|
||||
localStorage.removeItem('vpn_user')
|
||||
localStorage.removeItem('vpn_role')
|
||||
localStorage.removeItem('vpn_must_change')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,9 +46,14 @@ api.interceptors.request.use(cfg => {
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
const status = err.response?.status
|
||||
if (status === 401) {
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') location.href = '/login'
|
||||
} else if (status === 403 && err.response?.data?.must_change_password) {
|
||||
auth.mustChange = true
|
||||
localStorage.setItem('vpn_must_change', '1')
|
||||
if (location.pathname !== '/settings') location.href = '/settings'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
ProtocolIcon.vue — 专业协议品牌 SVG 图标组件 // Logan.Li
|
||||
WireGuard / OpenVPN 路径来自 Simple Icons (simpleicons.org),遵循 CC0 协议。
|
||||
IPSec / L2TP / IKEv2 / FRP / Custom 为原创矢量图标(网络/安全语义)。
|
||||
用法:<ProtocolIcon protocol="wireguard" :size="32" />
|
||||
-->
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:aria-label="label" role="img"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>{{ label }}</title>
|
||||
<path :d="pathData" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
protocol: { type: String, required: true },
|
||||
size: { type: [Number, String], default: 28 },
|
||||
})
|
||||
|
||||
// 品牌名称映射
|
||||
const NAMES = {
|
||||
wireguard: 'WireGuard',
|
||||
ipsec: 'IPSec',
|
||||
ikev2: 'IKEv2',
|
||||
l2tp: 'L2TP',
|
||||
frp: 'FRP',
|
||||
openvpn: 'OpenVPN',
|
||||
custom: '自定义协议',
|
||||
}
|
||||
|
||||
const label = computed(() => {
|
||||
const key = props.protocol.startsWith('custom:') ? 'custom' : props.protocol
|
||||
return NAMES[key] || props.protocol
|
||||
})
|
||||
|
||||
// SVG path 数据
|
||||
// WireGuard / OpenVPN: 官方 Simple Icons 路径 (https://simpleicons.org)
|
||||
// 其余: 原创矢量图标
|
||||
const ICON_PATHS = {
|
||||
// WireGuard 官方 Logo (Simple Icons, CC0)
|
||||
wireguard: 'M23.98 11.645S24.533 0 11.735 0C.418 0 .064 11.17.064 11.17S-1.6 24 11.997 24C25.04 24 23.98 11.645 23.98 11.645zM8.155 7.576c2.4-1.47 5.469-.571 6.618 1.638.218.419.246 1.063.108 1.503-.477 1.516-1.601 2.366-3.145 2.728.455-.39.817-.832.933-1.442a2.112 2.112 0 0 0-.364-1.677 2.14 2.14 0 0 0-2.465-.75c-.95.36-1.47 1.228-1.377 2.294.087.99.839 1.632 2.245 1.876-.21.111-.372.193-.53.281a5.113 5.113 0 0 0-1.644 1.43c-.143.192-.24.208-.458.075-2.827-1.729-3.009-6.067.078-7.956zM6.04 18.258c-.455.116-.895.286-1.359.438.227-1.532 2.021-2.943 3.539-2.782a3.91 3.91 0 0 0-.74 2.072c-.504.093-.98.155-1.44.272zM15.703 3.3c.448.017.898.01 1.347.02a2.324 2.324 0 0 1 .334.047 3.249 3.249 0 0 1-.34.434c-.16.15-.341.296-.573.069-.055-.055-.187-.042-.283-.044-.447-.005-.894-.02-1.34-.003a8.323 8.323 0 0 0-1.154.118c-.072.013-.178.25-.146.338.078.207.191.435.359.567.619.49 1.277.928 1.9 1.413.604.472 1.167.99 1.51 1.7.446.928.46 1.9.267 2.877-.322 1.63-1.147 2.98-2.483 3.962-.538.395-1.205.62-1.821.903-.543.25-1.1.465-1.644.712-.98.446-1.53 1.51-1.369 2.615.149 1.015 1.04 1.862 2.059 2.037 1.223.21 2.486-.586 2.785-1.83.336-1.397-.423-2.646-1.845-3.024l-.256-.066c.38-.17.708-.291 1.012-.458q.793-.437 1.558-.925c.15-.096.231-.096.36.014.977.846 1.56 1.898 1.724 3.187.27 2.135-.74 4.096-2.646 5.101-2.948 1.555-6.557-.215-7.208-3.484-.558-2.8 1.418-5.34 3.797-5.83 1.023-.211 1.958-.637 2.685-1.425.47-.508.697-.944.775-1.141a3.165 3.165 0 0 0 .217-1.158 2.71 2.71 0 0 0-.237-.992c-.248-.566-1.2-1.466-1.435-1.656l-2.24-1.754c-.079-.065-.168-.06-.36-.047-.23.016-.815.048-1.067-.018.204-.155.76-.38 1-.56-.726-.49-1.554-.314-2.315-.46.176-.328 1.046-.831 1.541-.888a7.323 7.323 0 0 0-.135-.822c-.03-.111-.154-.22-.263-.283-.262-.154-.541-.281-.843-.434a1.755 1.755 0 0 1 .906-.28 3.385 3.385 0 0 1 .908.088c.54.123.97.042 1.399-.324-.338-.136-.676-.26-1.003-.407a9.843 9.843 0 0 1-.942-.493c.85.118 1.671.437 2.54.32l.022-.118-2.018-.47c1.203-.11 2.323-.128 3.384.388.299.146.61.266.897.432.14.08.233.24.348.365.09.098.164.23.276.29.424.225.89.234 1.366.223l.01-.16c.479.15 1.017.702 1.017 1.105-.776 0-1.55-.003-2.325.004-.083 0-.165.061-.247.094.078.046.155.128.235.131z',
|
||||
|
||||
// OpenVPN 官方 Logo (Simple Icons, CC0)
|
||||
openvpn: 'M12 .357C5.385.357 0 5.69 0 12.254c0 4.36 2.358 8.153 5.896 10.204l.77-5.076a7.046 7.046 0 01-1.846-4.719c0-3.897 3.18-7.076 7.13-7.076 3.948 0 7.126 3.18 7.126 7.076 0 1.847-.717 3.488-1.846 4.77L18 22.51c3.59-2.05 6-5.899 6-10.258C24 5.69 18.615.357 12 .357zm-.05 8.157a3.786 3.786 0 00-3.796 3.795 3.738 3.738 0 002.461 3.54L9.13 23.643h5.64l-1.435-7.795c1.385-.564 2.41-1.898 2.41-3.54a3.786 3.786 0 00-3.795-3.794z',
|
||||
|
||||
// IPSec — 盾牌+锁(站点互联安全隧道语义)
|
||||
ipsec: 'M12 1L3 5v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V5l-9-4zm-1 5h2v3h-2V6zm0 4.5h2v7.5h-2v-7.5z M12 6.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3z',
|
||||
|
||||
// IKEv2 — 双向箭头环(密钥交换语义)
|
||||
ikev2: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.5 11H7.5l2 2-1.4 1.4L4.7 12l3.4-4.4L9.5 9l-2 2h9l-2-2 1.4-1.4 3.4 4.4-3.4 4.4L15.5 15l2-2z',
|
||||
|
||||
// L2TP — 手机+信号波(移动设备拨号语义)
|
||||
l2tp: 'M7 1C5.9 1 5 1.9 5 3v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2H7zm5 19c-.83 0-1.5-.67-1.5-1.5S11.17 17 12 17s1.5.67 1.5 1.5S12.83 20 12 20zm5-4H7V4h10v12z M9.5 6.5l2 2-2 2-1-1 1-1-1-1 1-1z M14.5 6.5l1 1-1 1 1 1-1 1-2-2 2-2z',
|
||||
|
||||
// FRP — 隧道+箭头穿透(内网穿透语义)
|
||||
frp: 'M3 8h2v8H3V8zm16 0h2v8h-2V8zM7 10h10v4H7v-4zm2 1.5l2 1.5-2 1.5v-3zm6 0v3l-2-1.5 2-1.5z M12 6l3 2H9l3-2zm0 12l-3-2h6l-3 2z',
|
||||
|
||||
// Custom — 拼图块(插件扩展语义)
|
||||
custom: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5a2.5 2.5 0 0 0-5 0V5H4a1 1 0 0 0-1 1v4h1.5a2.5 2.5 0 0 1 0 5H3v4a1 1 0 0 0 1 1h4v-1.5a2.5 2.5 0 0 1 5 0V20h4c1.1 0 2-.9 2-2v-4h1.5a2.5 2.5 0 0 0 0-5z',
|
||||
}
|
||||
|
||||
const pathData = computed(() => {
|
||||
const key = props.protocol.startsWith('custom:') ? 'custom' : props.protocol
|
||||
return ICON_PATHS[key] || ICON_PATHS.custom
|
||||
})
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/tokens.css'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
+115
-23
@@ -3,48 +3,83 @@ export const PROTOCOL_META = {
|
||||
wireguard: {
|
||||
name: 'WireGuard',
|
||||
desc: '性能最好的现代 VPN,内核级实现,漫游友好',
|
||||
color: '#88171a',
|
||||
icon: '⚡',
|
||||
color: '#c0392b',
|
||||
icon: 'wireguard',
|
||||
scenes: '内部接入 / 移动办公',
|
||||
fields: [
|
||||
{ key: 'address', label: '服务端 VPN 网段地址', placeholder: '10.8.0.1/24', required: true, tip: '服务端在 VPN 网段中的地址(CIDR)' },
|
||||
{ key: 'listen_port', label: '监听端口', type: 'number', default: 51820 },
|
||||
{ key: 'client_address', label: '客户端地址', placeholder: '10.8.0.2/32', tip: '分配给首个客户端的地址' },
|
||||
{ key: 'client_address', label: '首个客户端地址', placeholder: '10.8.0.2/32', tip: '创建后可在详情中继续添加 Peer' },
|
||||
{ key: 'public_endpoint', label: '公网接入点', placeholder: 'vpn.example.com:51820', tip: '客户端连接用,公网 IP:端口' },
|
||||
{ key: 'dns', label: 'DNS', default: '223.5.5.5' },
|
||||
{ key: 'allowed_ips', label: '客户端 AllowedIPs', default: '0.0.0.0/0, ::/0', tip: '全流量代理用 0.0.0.0/0;仅内网填内网网段' }
|
||||
{ key: 'allowed_ips', label: '客户端 AllowedIPs', default: '0.0.0.0/0, ::/0', tip: '全流量代理用 0.0.0.0/0;仅内网填内网网段' },
|
||||
{ key: 'keepalive', label: 'Keepalive(秒)', type: 'number', default: 25, tip: 'NAT 后客户端建议 15–25' },
|
||||
{ key: 'mtu', label: 'MTU', type: 'number', default: 0, tip: '0 表示默认;跨公网常见 1280–1420' },
|
||||
{ key: 'enable_nat', label: '启用 NAT 出网', type: 'switch', default: false, tip: '客户端需访问公网/其它网段时开启' },
|
||||
{ key: 'egress_interface', label: 'NAT 出口网卡', placeholder: 'eth0', tip: '留空则自动探测默认路由网卡', showIf: { enable_nat: true } }
|
||||
]
|
||||
},
|
||||
ipsec: {
|
||||
name: 'IPSec 站点互联',
|
||||
desc: '与第三方设备(Cisco/华为/飞塔等)做站点到站点对接',
|
||||
color: '#1f6fb2',
|
||||
icon: '🔗',
|
||||
icon: 'ipsec',
|
||||
scenes: '第三方对接 / 站点互联',
|
||||
fields: [
|
||||
{ key: 'vendor_template', label: '厂商模板', type: 'select', options: [
|
||||
{ value: 'generic', label: '通用标准(推荐)' },
|
||||
{ value: 'cisco', label: 'Cisco ISR/ASR' },
|
||||
{ value: 'fortigate', label: 'FortiGate' },
|
||||
{ value: 'huawei', label: '华为 USG/AR' },
|
||||
{ value: 'h3c', label: 'H3C' }
|
||||
], default: 'generic', tip: '自动预填 Phase1/Phase2 提议,仍可手动改' },
|
||||
{ key: 'public_endpoint', label: '本端公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'remote_gateway', label: '对端网关地址', placeholder: '5.6.7.8', required: true },
|
||||
{ key: 'local_subnets', label: '本端网段', placeholder: '192.168.1.0/24', required: true, tip: '多个网段用逗号分隔' },
|
||||
{ key: 'remote_subnets', label: '对端网段', placeholder: '10.0.0.0/24', required: true },
|
||||
{ key: 'psk', label: '预共享密钥 PSK', type: 'password', required: true, secret: true },
|
||||
{ key: 'ike_version', label: 'IKE 版本', type: 'select', options: ['ikev2', 'ikev1'], default: 'ikev2' },
|
||||
{ key: 'ike_version', label: 'IKE 版本', type: 'select', options: [
|
||||
{ value: 'ikev2', label: 'IKEv2' },
|
||||
{ value: 'ikev1', label: 'IKEv1' }
|
||||
], default: 'ikev2' },
|
||||
{ key: 'ike_proposal', label: 'Phase1 提议', placeholder: 'aes256-sha256-modp2048', tip: '加密-认证-DH组,需与对端一致' },
|
||||
{ key: 'esp_proposal', label: 'Phase2 提议', placeholder: 'aes256-sha256' },
|
||||
{ key: 'pfs_group', label: 'PFS DH 组', default: 'modp2048' },
|
||||
{ key: 'ike_lifetime', label: 'IKE 生存期(秒)', type: 'number', default: 86400 },
|
||||
{ key: 'local_id', label: '本端标识 ID', placeholder: '@vpn-local' },
|
||||
{ key: 'remote_id', label: '对端标识 ID', placeholder: '@vpn-remote' }
|
||||
]
|
||||
},
|
||||
ikev2: {
|
||||
name: 'IKEv2 远程接入',
|
||||
desc: '手机/笔记本原生拨号进内网,比 L2TP 更现代',
|
||||
color: '#0d7377',
|
||||
icon: 'ikev2',
|
||||
scenes: '员工远程办公 / 原生 VPN',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '服务器公网地址', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'local_id', label: '服务器 ID / 远程 ID', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: '客户端「远程 ID」与此一致' },
|
||||
{ key: 'psk', label: '机器认证 PSK', type: 'password', secret: true, tip: '留空自动生成;iOS/Android 共享密钥' },
|
||||
{ key: 'username', label: '默认拨号用户', default: 'vpnuser' },
|
||||
{ key: 'password', label: '默认拨号密码', type: 'password', secret: true, tip: '留空自动生成;更多账号在拨号用户页添加' },
|
||||
{ key: 'address_pool', label: '客户端地址池', default: '10.12.0.0/24' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' },
|
||||
{ key: 'local_ts', label: '放行网段', default: '0.0.0.0/0', tip: '0.0.0.0/0 为全流量;可改为内网 CIDR' },
|
||||
{ key: 'ike_proposal', label: 'IKE 提议', default: 'aes256-sha256-modp2048' },
|
||||
{ key: 'esp_proposal', label: 'ESP 提议', default: 'aes256-sha256' }
|
||||
]
|
||||
},
|
||||
l2tp: {
|
||||
name: 'L2TP/IPSec',
|
||||
desc: '兼容性最好,手机/老设备/路由器原生支持',
|
||||
color: '#5a8a3c',
|
||||
icon: '📱',
|
||||
icon: 'l2tp',
|
||||
scenes: '移动设备 / 老设备兼容',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '服务器公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'psk', label: 'IPSec 预共享密钥', type: 'password', required: true, secret: true },
|
||||
{ key: 'username', label: '拨号用户名', default: 'vpnuser' },
|
||||
{ key: 'password', label: '拨号密码', type: 'password', required: true, secret: true },
|
||||
{ key: 'username', label: '默认拨号用户名', default: 'vpnuser', tip: '可在「VPN 拨号用户」为本隧道添加更多账号' },
|
||||
{ key: 'password', label: '默认拨号密码', type: 'password', secret: true, tip: '若只使用拨号用户表可留空(创建时需至少有一种)' },
|
||||
{ key: 'ip_range', label: '客户端地址池', default: '10.9.0.100-10.9.0.200' },
|
||||
{ key: 'local_ip', label: '服务端隧道地址', default: '10.9.0.1' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
@@ -54,39 +89,87 @@ export const PROTOCOL_META = {
|
||||
name: 'FRP 内网穿透',
|
||||
desc: '国内刚需,TCP/UDP/HTTP 端口映射与反向代理',
|
||||
color: '#c0762b',
|
||||
icon: '🚇',
|
||||
icon: 'frp',
|
||||
scenes: '内网穿透 / 端口映射',
|
||||
fields: [
|
||||
{ key: 'mode', label: '运行模式', type: 'select', options: ['server', 'client'], default: 'server', tip: 'server=frps(有公网),client=frpc(内网侧)' },
|
||||
{ key: 'mode', label: '运行模式', type: 'select', options: [
|
||||
{ value: 'server', label: 'server (frps 公网侧)' },
|
||||
{ value: 'client', label: 'client (frpc 内网侧)' }
|
||||
], default: 'server', tip: 'server=frps(有公网),client=frpc(内网侧)' },
|
||||
{ key: 'bind_port', label: '绑定端口(server)', type: 'number', default: 7000, showIf: { mode: 'server' } },
|
||||
{ key: 'public_endpoint', label: '公网地址(server)', placeholder: '1.2.3.4', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_port', label: '管理面板端口(server)', type: 'number', default: 7500, showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_user', label: '管理面板账号(server)', default: 'admin', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_password', label: '管理面板密码(server)', type: 'password', secret: true, showIf: { mode: 'server' }, tip: '留空时自动生成强随机密码;编辑时留空保持原密码' },
|
||||
{ key: 'vhost_http_port', label: 'HTTP 虚拟主机端口', type: 'number', default: 0, showIf: { mode: 'server' }, tip: '0 表示不启用;HTTP 代理需 >0' },
|
||||
{ key: 'vhost_https_port', label: 'HTTPS 虚拟主机端口', type: 'number', default: 0, showIf: { mode: 'server' }, tip: '0 表示不启用' },
|
||||
{ key: 'allow_ports', label: '允许映射端口范围', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: '限制 frpc 可申请的 remotePort' },
|
||||
{ key: 'server_addr', label: 'frps 地址(client)', placeholder: '1.2.3.4', showIf: { mode: 'client' } },
|
||||
{ key: 'server_port', label: 'frps 端口(client)', type: 'number', default: 7000, showIf: { mode: 'client' } },
|
||||
{ key: 'token', label: '认证 Token', type: 'password', secret: true, tip: '留空自动生成' },
|
||||
{ key: 'proxy_name', label: '代理名称', placeholder: 'ssh' },
|
||||
{ key: 'proxy_type', label: '代理类型', type: 'select', options: ['tcp', 'udp', 'http', 'https'], default: 'tcp' },
|
||||
{ key: 'proxy_name', label: '代理名称', placeholder: 'ssh', default: 'ssh' },
|
||||
{ key: 'proxy_type', label: '代理类型', type: 'select', options: [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'udp', label: 'UDP' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'stcp', label: 'STCP(安全点对点)' },
|
||||
{ value: 'xtcp', label: 'XTCP(P2P)' }
|
||||
], default: 'tcp' },
|
||||
{ key: 'local_ip', label: '内网目标 IP', default: '127.0.0.1' },
|
||||
{ key: 'local_port', label: '内网目标端口', type: 'number', default: 22 },
|
||||
{ key: 'remote_port', label: '公网映射端口', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } },
|
||||
{ key: 'custom_domains', label: '绑定域名', placeholder: 'app.example.com, www.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'HTTP/HTTPS 代理必填,多个域名用逗号分隔' }
|
||||
{ key: 'custom_domains', label: '绑定域名', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'HTTP/HTTPS 代理必填' },
|
||||
{ key: 'secret_key', label: 'STCP/XTCP 密钥', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: '访问端与提供端需一致' }
|
||||
]
|
||||
},
|
||||
openvpn: {
|
||||
name: 'OpenVPN',
|
||||
desc: '最成熟的开源 VPN,兼容各类老项目与系统',
|
||||
color: '#e07b39',
|
||||
icon: '🛡️',
|
||||
icon: 'openvpn',
|
||||
scenes: '老项目兼容 / 通用接入',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '公网接入地址', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'port', label: '端口', type: 'number', default: 1194 },
|
||||
{ key: 'proto', label: '传输协议', type: 'select', options: ['udp', 'tcp'], default: 'udp' },
|
||||
{ key: 'subnet', label: 'VPN 网段', default: '10.10.0.0 255.255.255.0' },
|
||||
{ key: 'cipher', label: '加密算法', type: 'select', options: ['AES-256-GCM', 'AES-128-GCM', 'AES-256-CBC'], default: 'AES-256-GCM' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
{ key: 'proto', label: '传输协议', type: 'select', options: [
|
||||
{ value: 'udp', label: 'UDP(推荐,性能更好)' },
|
||||
{ value: 'tcp-server', label: 'TCP(穿透限制网络更稳)' }
|
||||
], default: 'udp', tip: '可随时在编辑中切换;切换后需重启专线,客户端需重新下载配置' },
|
||||
{ key: 'subnet', label: 'VPN 网段', default: '10.10.0.0 255.255.255.0', tip: '如 10.8.0.0 255.255.240.0 可容纳更多客户端(/20)' },
|
||||
{ key: 'cipher', label: '加密算法', type: 'select', options: [
|
||||
{ value: 'AES-256-GCM', label: 'AES-256-GCM' },
|
||||
{ value: 'AES-128-GCM', label: 'AES-128-GCM' },
|
||||
{ value: 'AES-256-CBC', label: 'AES-256-CBC' }
|
||||
], default: 'AES-256-GCM' },
|
||||
{ key: 'auth_mode', label: '认证方式', type: 'select', options: [
|
||||
{ value: 'certificate', label: '仅证书' },
|
||||
{ value: 'password', label: '仅用户名密码' },
|
||||
{ value: 'certificate+password', label: '证书 + 密码' }
|
||||
], default: 'certificate', tip: '密码模式对齐生产 verify-client-cert none + username-as-common-name' },
|
||||
{ key: 'mfa_mode', label: '拨号 MFA (2FA)', type: 'select', options: [
|
||||
{ value: 'off', label: '关闭' },
|
||||
{ value: 'optional', label: '可选(用户绑定后才校验)' },
|
||||
{ value: 'required', label: '强制(所有拨号用户必须 MFA)' }
|
||||
], default: 'off', tip: 'static-challenge + TOTP;在拨号用户页管理 OTP', showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_slop', label: 'OTP 时钟容差(秒)', type: 'number', default: 90, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_max_failures', label: 'OTP 失败锁定次数', type: 'number', default: 5, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_lockout_sec', label: 'OTP 锁定时长(秒)', type: 'number', default: 300, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'otp_disallow_reuse', label: '禁止重复使用同码', type: 'switch', default: true, showIf: { auth_mode: ['password', 'certificate+password'] } },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' },
|
||||
{ key: 'redirect_gateway', label: '全流量代理', type: 'switch', default: true, tip: '关闭后仅推送下方内网路由(生产常只推指定路由)' },
|
||||
{ key: 'push_routes', label: '推送内网路由', placeholder: '192.168.0.0/16,10.0.0.0/8', tip: 'CIDR 逗号分隔' },
|
||||
{ key: 'enable_nat', label: '启用 NAT 出网', type: 'switch', default: false },
|
||||
{ key: 'max_clients', label: '最大客户端数', type: 'number', default: 1000, tip: '0 不限制;生产常见 1000' },
|
||||
{ key: 'keepalive_interval', label: 'Keepalive 间隔', type: 'number', default: 5, tip: '生产常用 keepalive 5 30' },
|
||||
{ key: 'keepalive_timeout', label: 'Keepalive 超时', type: 'number', default: 30 },
|
||||
{ key: 'tun_mtu', label: 'tun-mtu', type: 'number', default: 1500, tip: '生产常用 1500' },
|
||||
{ key: 'tun_mtu_extra', label: 'tun-mtu-extra', type: 'number', default: 32 },
|
||||
{ key: 'mssfix', label: 'mssfix', type: 'number', default: 0, tip: '0=不设置;TCP 生产常用 1450,选 TCP 时默认 1450' },
|
||||
{ key: 'duplicate_cn', label: '允许 duplicate-cn', type: 'switch', default: false, tip: '同用户名/证书多终端同时在线;密码模式默认会自动开启' },
|
||||
{ key: 'reneg_sec', label: 'reneg-sec', type: 'number', default: 0, tip: '0=禁用 TLS 重协商(生产/MFA 推荐);证书-only 默认可 3600' },
|
||||
{ key: 'run_as_user', label: '降权用户', placeholder: 'openvpn', tip: '对应 user openvpn;需系统已有该用户,留空不降权' },
|
||||
{ key: 'run_as_group', label: '降权组', placeholder: 'openvpn', tip: '对应 group openvpn' }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -99,11 +182,12 @@ export const CUSTOM_FIELDS = [
|
||||
]
|
||||
|
||||
export const PROTOCOL_COLORS = {
|
||||
wireguard: '#88171a',
|
||||
wireguard: '#c0392b',
|
||||
ipsec: '#1f6fb2',
|
||||
l2tp: '#5a8a3c',
|
||||
frp: '#c0762b',
|
||||
openvpn: '#e07b39'
|
||||
ikev2: '#0d7377',
|
||||
l2tp: '#4a8a2c',
|
||||
frp: '#b8651b',
|
||||
openvpn: '#cc6529'
|
||||
}
|
||||
|
||||
export function protocolDisplay(t) {
|
||||
@@ -115,3 +199,11 @@ export function protocolColor(t) {
|
||||
if (t.startsWith('custom:')) return '#7b5ea7'
|
||||
return PROTOCOL_COLORS[t] || '#606266'
|
||||
}
|
||||
|
||||
/** 解析 select 的 options(兼容字符串与 {value,label}) */
|
||||
export function optionValue(o) {
|
||||
return typeof o === 'object' && o !== null ? o.value : o
|
||||
}
|
||||
export function optionLabel(o) {
|
||||
return typeof o === 'object' && o !== null ? (o.label || o.value) : o
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { auth } from './api'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('./views/Login.vue'), meta: { public: true } },
|
||||
{ path: '/mfa-verify', component: () => import('./views/MfaVerify.vue'), meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('./views/Layout.vue'),
|
||||
@@ -10,14 +11,16 @@ const routes = [
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { title: '总览' } },
|
||||
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { title: '专线列表' } },
|
||||
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { title: '在线会话' } },
|
||||
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { title: '创建专线' } },
|
||||
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { title: '编辑专线' } },
|
||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { title: 'IPSec 第三方对接' } },
|
||||
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { title: '节点管理' } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { title: '用户与 LDAP' } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { title: '用户管理' } },
|
||||
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { title: 'VPN 拨号用户' } },
|
||||
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { title: '协议插件' } },
|
||||
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } }
|
||||
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } },
|
||||
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { title: '页面不存在' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; margin: 0; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; }
|
||||
body { background: #f5f7fa; }
|
||||
body { background: var(--bg-page); }
|
||||
|
||||
.stat-card { border-radius: 10px; padding: 20px; color: #fff; position: relative; overflow: hidden; }
|
||||
.stat-card .num { font-size: 32px; font-weight: 700; margin-top: 8px; }
|
||||
.stat-card { border-radius: var(--radius-md); padding: var(--space-5); color: #fff; position: relative; overflow: hidden; }
|
||||
.stat-card .num { font-size: 32px; font-weight: 700; margin-top: var(--space-2); }
|
||||
.stat-card .label { opacity: .85; font-size: 14px; }
|
||||
|
||||
.page-card { border-radius: 10px; }
|
||||
.page-card { border-radius: var(--radius-md); }
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
.mono { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.protocol-tag { font-weight: 600; }
|
||||
|
||||
/* 全局焦点可见样式(可访问性) */
|
||||
*:focus-visible { outline: 2px solid var(--brand-primary); outline-offset: 2px; }
|
||||
@@ -0,0 +1,78 @@
|
||||
/* ============================================
|
||||
* VPN 管理平台 设计令牌 (Design Tokens) // Logan.Li
|
||||
* 统一全站颜色/间距/圆角/阴影,消灭散落的硬编码值。
|
||||
* 在 element-plus css 之后引入,覆盖关键 EP 变量。
|
||||
* ============================================ */
|
||||
|
||||
:root {
|
||||
/* ---- 品牌色(沿用深蓝系,统一为可引用变量)---- */
|
||||
--brand: #1a2a4a;
|
||||
--brand-accent: #2b5876;
|
||||
--brand-primary: #409eff;
|
||||
--brand-grad: linear-gradient(135deg, var(--brand) 0%, var(--brand-accent) 100%);
|
||||
|
||||
/* ---- 语义色 ---- */
|
||||
--color-success: #67c23a;
|
||||
--color-warning: #e6a23c;
|
||||
--color-danger: #f56c6c;
|
||||
--color-info: #909399;
|
||||
|
||||
/* ---- 文本层级(修对比度:placeholder 不再用 #c0c4cc)---- */
|
||||
--text-primary: #303133;
|
||||
--text-regular: #606266;
|
||||
--text-secondary: #909399;
|
||||
--text-placeholder: #a8abb2;
|
||||
|
||||
/* ---- 表面/背景 ---- */
|
||||
--bg-page: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--bg-inset: #f6f8fa;
|
||||
--bg-sidebar: #1a2a4a;
|
||||
--bg-sidebar-hover: rgba(255, 255, 255, 0.08);
|
||||
--bg-sidebar-active: rgba(64, 158, 255, 0.15);
|
||||
--bg-hover: #f5f7fa;
|
||||
|
||||
/* ---- 边框 ---- */
|
||||
--border-base: #e8eaee;
|
||||
--border-light: #f0f2f5;
|
||||
|
||||
/* ---- 圆角(统一为 3 级)---- */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
|
||||
/* ---- 阴影(统一为 3 级)---- */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* ---- 间距(8px 网格)---- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
|
||||
/* ---- 覆盖 Element Plus 关键变量,使组件库与品牌统一 ---- */
|
||||
--el-color-primary: #409eff;
|
||||
--el-color-success: #67c23a;
|
||||
--el-color-warning: #e6a23c;
|
||||
--el-color-danger: #f56c6c;
|
||||
--el-border-radius-base: 6px;
|
||||
--el-font-size-base: 14px;
|
||||
}
|
||||
|
||||
/* ---- 工具类 ---- */
|
||||
.row-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
.fade-in { animation: vpn-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
||||
|
||||
@keyframes vpn-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 减弱动画偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in { animation: none; }
|
||||
}
|
||||
@@ -1,27 +1,49 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 空状态引导:0 条专线时展示 -->
|
||||
<el-card v-if="ov.total === 0" class="page-card welcome-card fade-in" shadow="never">
|
||||
<div class="welcome-inner">
|
||||
<div class="welcome-icon"><el-icon :size="32" color="var(--brand-primary)"><Connection /></el-icon></div>
|
||||
<div class="welcome-text">
|
||||
<h3>欢迎使用 VPN 管理平台</h3>
|
||||
<p>还没有专线,3 步即可创建第一条:选择协议 → 填写参数 → 自动生成配置</p>
|
||||
</div>
|
||||
<el-button type="primary" size="large" :icon="Plus" @click="$router.push('/tunnels/create')">立即创建专线</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计卡片:渐变 icon 块 + 数值,借鉴 opsx-web-lite/aiops -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#4a6fa5,#2b4a7a)">
|
||||
<div class="label">专线总数</div><div class="num">{{ ov.total ?? '-' }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#4caf7d,#2e7d5b)">
|
||||
<div class="label">运行中</div><div class="num">{{ ov.running ?? '-' }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#e0935a,#c0762b)">
|
||||
<div class="label">节点 / 插件</div><div class="num">{{ ov.nodes ?? 0 }} / {{ ov.plugins ?? 0 }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#7b5ea7,#5a4080)">
|
||||
<div class="label">累计流量 ↓↑</div><div class="num" style="font-size:22px">{{ fmtBytes(ov.rx_bytes) }} / {{ fmtBytes(ov.tx_bytes) }}</div></div></el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" v-for="(card, i) in statCards" :key="i">
|
||||
<div class="stat-card fade-in" :class="{ clickable: card.to }" :style="{ animationDelay: i * 0.05 + 's' }"
|
||||
@click="card.to && $router.push(card.to)">
|
||||
<div class="stat-icon" :style="{ background: card.iconBg }">
|
||||
<el-icon :size="26" color="#fff"><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-num" :style="card.numStyle || {}">{{ card.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="10">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>协议分布</b></template>
|
||||
<div ref="pieRef" style="height:280px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>协议引擎状态</b> <span class="dep-hint">系统依赖检测结果</span></template>
|
||||
<el-table :data="protocols" size="large">
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<b>协议引擎状态</b>
|
||||
<span class="dep-hint">系统依赖检测结果</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="protocols" size="default" v-loading="loadingProto">
|
||||
<el-table-column label="协议" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="protocol-tag" :style="{ color: protocolColor(row.name) }">{{ row.display_name }}</span>
|
||||
@@ -30,18 +52,27 @@
|
||||
<el-table-column label="依赖状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.available ? 'success' : 'warning'" size="small">
|
||||
{{ row.available ? '已就绪' : '未部署' }}
|
||||
{{ row.available ? '已就绪' : '演示模式' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dependency" label="说明" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<template v-if="!row.available && !row.custom">
|
||||
<el-button v-if="deployableProtocols.includes(row.name)" size="small" type="primary"
|
||||
:loading="deploying === row.name" @click="deployProtocol(row.name)">下载部署</el-button>
|
||||
<el-button v-else size="small" @click="showInstallCmd(row.name)">安装命令</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="ov.expiring?.length">
|
||||
<template #header><b style="color:#e6a23c">⚠ 即将到期的专线(7 天内)</b></template>
|
||||
<template #header><b style="color:var(--color-warning)">即将到期的专线(7 天内)</b></template>
|
||||
<el-table :data="ov.expiring" size="small">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="协议">
|
||||
@@ -56,16 +87,61 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const ov = ref({})
|
||||
const protocols = ref([])
|
||||
const loadingProto = ref(true)
|
||||
const pieRef = ref(null)
|
||||
let chart = null
|
||||
|
||||
// ---- 协议部署 ----
|
||||
const deployableProtocols = ['frp', 'openvpn']
|
||||
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
|
||||
const deploying = ref('')
|
||||
|
||||
async function deployProtocol(name) {
|
||||
const pkg = protocolToPkg[name] || name
|
||||
deploying.value = name
|
||||
try {
|
||||
const { data } = await api.post(`/binaries/install/${pkg}`)
|
||||
ElMessage.success(`${name} ${data.version} 部署成功`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || `${name} 部署失败`)
|
||||
} finally { deploying.value = '' }
|
||||
}
|
||||
|
||||
async function showInstallCmd(name) {
|
||||
const pkg = protocolToPkg[name] || name
|
||||
try {
|
||||
const { data } = await api.get(`/binaries/install-hint/${pkg}`)
|
||||
const cmd = data.command || '请联系系统管理员安装'
|
||||
const pmLabel = data.pm ? `(${data.pm})` : '(apt 默认)'
|
||||
// 构建其他平台的命令列表
|
||||
let otherCmds = ''
|
||||
if (data.all_commands) {
|
||||
for (const [pm, c] of Object.entries(data.all_commands)) {
|
||||
if (c !== cmd) {
|
||||
otherCmds += `<div style="margin:4px 0"><span style="display:inline-block;min-width:70px;font-weight:600">${pm}:</span> <code style="font-size:12px">${c}</code></div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
const otherSection = otherCmds ? `<hr style="margin:12px 0;border:none;border-top:1px solid #e8eaee"><div style="font-size:12px;color:#909399;margin-bottom:6px">其他发行版:</div>${otherCmds}` : ''
|
||||
ElMessageBox.alert(
|
||||
`<div style="margin-bottom:8px"><b>推荐命令${pmLabel}</b></div><pre style="background:#f0f9eb;border:1px solid #67c23a;padding:12px;border-radius:6px;font-family:monospace;white-space:pre-wrap;font-size:13px">${cmd}</pre>${otherSection}`,
|
||||
`${name} 安装命令`, { dangerouslyUseHTMLString: true, confirmButtonText: '复制推荐命令',
|
||||
callback: (action) => { if (action === 'confirm') navigator.clipboard.writeText(cmd) } })
|
||||
} catch {
|
||||
ElMessage.error('获取安装命令失败')
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
@@ -75,11 +151,22 @@ function fmtBytes(n) {
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ label: '专线总数', value: ov.value.total ?? '-', icon: Connection, iconBg: 'linear-gradient(135deg,#4a6fa5,#2b4a7a)', to: '/tunnels' },
|
||||
{ label: '运行中', value: ov.value.running ?? '-', icon: Monitor, iconBg: 'linear-gradient(135deg,#4caf7d,#2e7d5b)', to: '/tunnels?state=running' },
|
||||
{ label: '在线用户', value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' },
|
||||
{ label: '累计流量 ↓↑', value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } },
|
||||
])
|
||||
|
||||
async function load() {
|
||||
const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')])
|
||||
ov.value = o
|
||||
protocols.value = p
|
||||
renderPie()
|
||||
// 轮询可见性守卫:标签页隐藏时跳过(P1 修复)
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')])
|
||||
ov.value = o
|
||||
protocols.value = p
|
||||
renderPie()
|
||||
} catch { /* 忽略轮询错误 */ } finally { loadingProto.value = false }
|
||||
}
|
||||
|
||||
function renderPie() {
|
||||
@@ -101,11 +188,55 @@ function renderPie() {
|
||||
})
|
||||
}
|
||||
|
||||
// ECharts resize 修复(P1):窗口/侧栏变化时重绘
|
||||
function handleResize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(() => { load(); timer = setInterval(load, 15000) })
|
||||
onUnmounted(() => { clearInterval(timer); chart?.dispose() })
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 15000)
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dep-hint { font-size: 12px; color: #909399; font-weight: normal; margin-left: 8px; }
|
||||
.stat-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md); padding: var(--space-5);
|
||||
display: flex; align-items: center; gap: var(--space-4);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
||||
.stat-card.clickable { cursor: pointer; }
|
||||
|
||||
.welcome-card { margin-bottom: var(--space-4); }
|
||||
.welcome-inner {
|
||||
display: flex; align-items: center; gap: var(--space-5); padding: var(--space-4) 0;
|
||||
}
|
||||
.welcome-icon {
|
||||
width: 64px; height: 64px; border-radius: var(--radius-md); flex-shrink: 0;
|
||||
background: var(--bg-inset); border: 1px solid var(--border-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.welcome-text { flex: 1; }
|
||||
.welcome-text h3 { margin: 0 0 var(--space-1); font-size: 18px; color: var(--text-primary); }
|
||||
.welcome-text p { margin: 0; font-size: 14px; color: var(--text-secondary); }
|
||||
.stat-icon {
|
||||
width: 56px; height: 56px; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-body { min-width: 0; flex: 1; }
|
||||
.stat-label { font-size: 13px; color: var(--text-secondary); margin-bottom: var(--space-1); }
|
||||
.stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
||||
|
||||
.dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; }
|
||||
</style>
|
||||
@@ -65,7 +65,7 @@
|
||||
</el-form>
|
||||
<el-button style="width:100%" :loading="checking" @click="compare">开始对比检查</el-button>
|
||||
<div v-if="compareResult" style="margin-top:14px">
|
||||
<el-alert v-if="compareResult.consistent" type="success" title="✓ 双方参数一致,可以建立隧道" :closable="false" />
|
||||
<el-alert v-if="compareResult.consistent" type="success" title="双方参数一致,可以建立隧道" :closable="false" show-icon />
|
||||
<div v-else>
|
||||
<el-alert type="error" title="发现参数不一致:" :closable="false" />
|
||||
<ul class="diff-list">
|
||||
|
||||
+110
-27
@@ -2,28 +2,37 @@
|
||||
<el-container class="layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">🔐</span>
|
||||
<el-icon :size="22" color="#7dd3fc"><Lock /></el-icon>
|
||||
<span class="logo-text">VPN 管理平台</span>
|
||||
</div>
|
||||
<el-menu :default-active="$route.path" router background-color="#1a2a4a" text-color="#a8b8d8"
|
||||
<el-menu :default-active="activeMenu" router :background-color="sideBg" text-color="#a8b8d8"
|
||||
active-text-color="#ffffff" class="menu">
|
||||
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>总览</el-menu-item>
|
||||
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>专线列表</el-menu-item>
|
||||
<el-menu-item index="/tunnels/create"><el-icon><CirclePlus /></el-icon>创建专线</el-menu-item>
|
||||
<el-menu-item index="/ipsec-peer"><el-icon><Link /></el-icon>IPSec 对接</el-menu-item>
|
||||
<el-menu-item index="/nodes"><el-icon><Share /></el-icon>节点管理</el-menu-item>
|
||||
<el-menu-item index="/users"><el-icon><UserFilled /></el-icon>用户与 LDAP</el-menu-item>
|
||||
<el-menu-item index="/vpn-users"><el-icon><User /></el-icon>VPN 拨号用户</el-menu-item>
|
||||
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>协议插件</el-menu-item>
|
||||
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>系统设置</el-menu-item>
|
||||
<el-menu-item-group title="专线运维">
|
||||
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>总览</el-menu-item>
|
||||
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>专线列表</el-menu-item>
|
||||
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>在线会话</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="auth.isAdmin()" title="资源管理">
|
||||
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>节点管理</el-menu-item>
|
||||
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>VPN 拨号用户</el-menu-item>
|
||||
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>协议插件</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group title="系统">
|
||||
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>用户管理</el-menu-item>
|
||||
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>系统设置</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<span class="page-title">{{ $route.meta.title }}</span>
|
||||
<el-dropdown @command="onCommand">
|
||||
<span class="user">
|
||||
<el-icon><Avatar /></el-icon> {{ auth.username }} <el-icon><ArrowDown /></el-icon>
|
||||
<span class="user" role="button" aria-label="账户菜单" tabindex="0">
|
||||
<el-icon><Avatar /></el-icon> {{ auth.username }}
|
||||
<el-tag size="small" style="margin-left:6px" :type="roleTagType">
|
||||
{{ roleLabel }}
|
||||
</el-tag>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
@@ -37,32 +46,106 @@
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 右上角修改密码弹窗(不必跳转整页) -->
|
||||
<el-dialog v-model="pwdDialog" title="修改密码" width="420px" align-center>
|
||||
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
|
||||
title="首次登录或密码已被重置,请先修改密码" style="margin-bottom:16px" />
|
||||
<el-form label-width="90px" @submit.prevent="changePwd">
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="pwd.old_password" type="password" show-password autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password"
|
||||
placeholder="至少 8 位" />
|
||||
<div class="pwd-hint" v-if="pwd.new_password">强度:{{ pwdStrength }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="pwd.confirm" type="password" show-password autocomplete="new-password"
|
||||
@keyup.enter="changePwd" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="pwdDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pwdLoading" @click="changePwd">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DataAnalysis, Connection, CirclePlus, Link, Share, Grid, Setting, Avatar, ArrowDown, UserFilled, User } from '@element-plus/icons-vue'
|
||||
import { auth } from '../api'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const sideBg = 'var(--bg-sidebar)'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const roleLabel = computed(() => ({ admin: '管理员', operator: '操作员', viewer: '只读' }[auth.role] || auth.role))
|
||||
const roleTagType = computed(() => auth.role === 'admin' ? 'danger' : auth.role === 'operator' ? 'warning' : 'info')
|
||||
|
||||
// 子路由(创建/编辑专线)归一到 /tunnels 保持高亮
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path.startsWith('/tunnels')) return '/tunnels'
|
||||
return route.path
|
||||
})
|
||||
|
||||
// ---- 右上角修改密码弹窗 ----
|
||||
const pwdDialog = ref(false)
|
||||
const pwdLoading = ref(false)
|
||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||
|
||||
const pwdStrength = computed(() => {
|
||||
const p = pwd.value.new_password
|
||||
if (!p) return ''
|
||||
let score = 0
|
||||
if (p.length >= 8) score++
|
||||
if (p.length >= 12) score++
|
||||
if (/[A-Z]/.test(p)) score++
|
||||
if (/[0-9]/.test(p)) score++
|
||||
if (/[^A-Za-z0-9]/.test(p)) score++
|
||||
return ['很弱', '弱', '一般', '较强', '强'][Math.min(score - 1, 4)] || '很弱'
|
||||
})
|
||||
|
||||
function onCommand(cmd) {
|
||||
if (cmd === 'logout') { auth.logout(); router.push('/login') }
|
||||
if (cmd === 'password') router.push('/settings')
|
||||
if (cmd === 'password') { pwd.value = { old_password: '', new_password: '', confirm: '' }; pwdDialog.value = true }
|
||||
}
|
||||
|
||||
async function changePwd() {
|
||||
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning('两次新密码不一致')
|
||||
if (pwd.value.new_password.length < 8) return ElMessage.warning('新密码至少 8 位')
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
await api.post('/password', { old_password: pwd.value.old_password, new_password: pwd.value.new_password })
|
||||
auth.clearMustChange()
|
||||
ElMessage.success('密码已修改')
|
||||
pwdDialog.value = false
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '修改失败')
|
||||
} finally { pwdLoading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { height: 100vh; }
|
||||
.aside { background: #1a2a4a; }
|
||||
.logo { display: flex; align-items: center; gap: 10px; padding: 18px 20px; color: #fff; font-size: 17px; font-weight: 700; }
|
||||
.logo-icon { font-size: 22px; }
|
||||
.layout { height: 100dvh; }
|
||||
.aside { background: var(--bg-sidebar); }
|
||||
.logo { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-5); color: #fff; font-size: 17px; font-weight: 700; }
|
||||
.menu { border-right: none; }
|
||||
.header {
|
||||
background: #fff; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid #e8eaee; box-shadow: 0 1px 4px rgba(0,0,0,.04);
|
||||
.menu :deep(.el-menu-item-group__title) {
|
||||
color: rgba(255,255,255,0.35); font-size: 11px; padding: 12px 20px 4px;
|
||||
letter-spacing: 1px; text-transform: uppercase;
|
||||
}
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.user { cursor: pointer; color: #606266; display: flex; align-items: center; gap: 4px; }
|
||||
.main { background: #f5f7fa; padding: 20px; overflow-y: auto; }
|
||||
.header {
|
||||
background: var(--bg-card); display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-base); box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.page-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||
.user { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: var(--space-1); }
|
||||
.user:focus-visible { outline: 2px solid var(--brand-primary); border-radius: var(--radius-sm); }
|
||||
.main { background: var(--bg-page); padding: var(--space-5); overflow-y: auto; }
|
||||
.pwd-hint { font-size: 12px; color: var(--text-secondary); margin-top: var(--space-1); }
|
||||
</style>
|
||||
+155
-40
@@ -1,27 +1,80 @@
|
||||
<template>
|
||||
<div class="login-wrap">
|
||||
<div class="login-box">
|
||||
<div class="brand">
|
||||
<div class="logo">🔐</div>
|
||||
<h2>VPN 专线管理平台</h2>
|
||||
<p>WireGuard · IPSec · L2TP · FRP · OpenVPN</p>
|
||||
<div class="login-container">
|
||||
<!-- 左面板:品牌展示(小屏隐藏) -->
|
||||
<div class="login-left">
|
||||
<div class="brand-content fade-in">
|
||||
<div class="brand-logo">
|
||||
<el-icon :size="32" color="#7dd3fc"><Lock /></el-icon>
|
||||
</div>
|
||||
<h1 class="brand-title">VPN 专线管理平台</h1>
|
||||
<p class="brand-subtitle">统一管理多协议 VPN,配置简单、安全可控</p>
|
||||
<p class="brand-desc">WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件</p>
|
||||
<div class="brand-features">
|
||||
<div class="feature-item">
|
||||
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
|
||||
<div>
|
||||
<strong>多协议统一管理</strong>
|
||||
<span>WireGuard / IPSec / L2TP / FRP / OpenVPN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
|
||||
<div>
|
||||
<strong>企业级安全</strong>
|
||||
<span>密钥 AES-GCM 加密 + 双因素认证</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
|
||||
<div>
|
||||
<strong>一键创建配置</strong>
|
||||
<span>向导式生成,客户端配置秒级下载</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
|
||||
<div>
|
||||
<strong>轻量零依赖</strong>
|
||||
<span>单二进制交付,无需 Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右面板:登录表单 -->
|
||||
<div class="login-right">
|
||||
<div class="login-form-wrapper fade-in">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">欢迎登录</h2>
|
||||
<p class="form-subtitle">请输入账号密码登录管理平台</p>
|
||||
</div>
|
||||
|
||||
<!-- 内联错误提示 -->
|
||||
<el-alert v-if="loginError" type="error" :title="loginError" show-icon :closable="false"
|
||||
style="margin-bottom: 20px" />
|
||||
|
||||
<form @submit.prevent="doLogin">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-user">用户名</label>
|
||||
<el-input id="login-user" v-model="username" placeholder="请输入用户名" size="large"
|
||||
:prefix-icon="User" autocomplete="username" @keyup.enter="doLogin" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="login-pass">密码</label>
|
||||
<el-input id="login-pass" v-model="password" type="password" placeholder="请输入密码"
|
||||
size="large" show-password :prefix-icon="Lock"
|
||||
autocomplete="current-password" @keyup.enter="doLogin" />
|
||||
</div>
|
||||
<el-button type="primary" size="large" native-type="submit" style="width:100%" :loading="loading">
|
||||
登 录
|
||||
</el-button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>默认账号 <strong>admin / admin123</strong>,登录后请立即修改</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-form @submit.prevent="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="username" placeholder="用户名" size="large" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="password" type="password" placeholder="密码" size="large" show-password
|
||||
:prefix-icon="Lock" @keyup.enter="doLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="mfaCode" placeholder="MFA 验证码(未启用可留空)" size="large" @keyup.enter="doLogin" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="hint">默认账号 admin / admin123,登录后请立即修改</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,24 +83,44 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { User, Lock, CircleCheck } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const mfaCode = ref('')
|
||||
const loading = ref(false)
|
||||
const loginError = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
async function doLogin() {
|
||||
if (!username.value || !password.value) return ElMessage.warning('请输入用户名和密码')
|
||||
loginError.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
loginError.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/login', { username: username.value, password: password.value, mfa_code: mfaCode.value })
|
||||
auth.login(data.token, data.username)
|
||||
router.push('/')
|
||||
const { data } = await api.post('/login', { username: username.value, password: password.value })
|
||||
auth.login(data.token, data.username, data.role, data.must_change_password)
|
||||
ElMessage.success('登录成功')
|
||||
if (data.must_change_password) {
|
||||
ElMessage.warning('首次登录请先修改密码')
|
||||
router.push('/settings')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '登录失败')
|
||||
// 检测 MFA 要求:后端返回 mfa_required: true 表示需要双因素验证
|
||||
if (e.response?.data?.mfa_required) {
|
||||
// 将凭据暂存 sessionStorage,跳转到独立 MFA 验证页(参考 opsx-web-lite 两步登录)
|
||||
sessionStorage.setItem('pendingLogin', JSON.stringify({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}))
|
||||
router.push('/mfa-verify')
|
||||
return
|
||||
}
|
||||
loginError.value = e.response?.data?.error || '登录失败,请检查网络或联系管理员'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -55,17 +128,59 @@ async function doLogin() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap {
|
||||
height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #1a2a4a 0%, #2b5876 100%);
|
||||
.login-container {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(400px, 0.85fr);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
.login-box {
|
||||
width: 380px; background: #fff; border-radius: 14px; padding: 40px 36px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
|
||||
/* ---- 左面板 ---- */
|
||||
.login-left {
|
||||
background: var(--brand-grad);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: var(--space-6); overflow: hidden;
|
||||
}
|
||||
.brand-content { width: min(100%, 480px); color: #f8fafc; }
|
||||
.brand-logo {
|
||||
width: 56px; height: 56px; border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-5);
|
||||
}
|
||||
.brand-title { font-size: 32px; font-weight: 700; margin: 0 0 var(--space-2); color: #fff; letter-spacing: 0.5px; }
|
||||
.brand-subtitle { font-size: 16px; color: rgba(255, 255, 255, 0.9); margin: 0 0 var(--space-1); }
|
||||
.brand-desc { font-size: 13px; color: rgba(255, 255, 255, 0.65); margin: 0; }
|
||||
.brand-features { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); margin-top: var(--space-6); }
|
||||
.feature-item {
|
||||
display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-3);
|
||||
border-radius: var(--radius-sm); border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.feature-item strong { display: block; font-size: 13px; color: #e2e8f0; font-weight: 600; }
|
||||
.feature-item span { font-size: 12px; color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
/* ---- 右面板 ---- */
|
||||
.login-right {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: var(--space-6); background: var(--bg-card);
|
||||
}
|
||||
.login-form-wrapper { width: min(100%, 400px); }
|
||||
.form-header { margin-bottom: var(--space-6); }
|
||||
.form-title { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 var(--space-1); }
|
||||
.form-subtitle { font-size: 14px; color: var(--text-secondary); margin: 0; }
|
||||
.form-group { margin-bottom: var(--space-4); }
|
||||
.form-label {
|
||||
display: block; font-size: 13px; font-weight: 500;
|
||||
color: var(--text-regular); margin-bottom: var(--space-2);
|
||||
}
|
||||
.login-footer {
|
||||
margin-top: var(--space-6); padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-base); text-align: center;
|
||||
}
|
||||
.login-footer p { font-size: 12px; color: var(--text-secondary); margin: 0; }
|
||||
.login-footer strong { color: var(--text-regular); font-weight: 600; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-container { grid-template-columns: 1fr; }
|
||||
.login-left { display: none; }
|
||||
}
|
||||
.brand { text-align: center; margin-bottom: 28px; }
|
||||
.logo { font-size: 44px; }
|
||||
.brand h2 { margin: 10px 0 6px; color: #1a2a4a; }
|
||||
.brand p { color: #909399; font-size: 13px; margin: 0; }
|
||||
.hint { text-align: center; color: #c0c4cc; font-size: 12px; margin-top: 18px; }
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="mfa-container">
|
||||
<div class="mfa-card fade-in">
|
||||
<div class="mfa-header">
|
||||
<div class="mfa-icon">
|
||||
<el-icon :size="28" color="var(--brand-primary)"><Key /></el-icon>
|
||||
</div>
|
||||
<h2>双因素认证</h2>
|
||||
<p>请输入 Authenticator 应用中显示的 6 位动态验证码</p>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" style="margin-bottom:20px" />
|
||||
|
||||
<!-- 6 位验证码输入 -->
|
||||
<div class="code-inputs">
|
||||
<el-input
|
||||
v-for="(_, i) in 6" :key="i"
|
||||
:ref="el => setDigitRef(el, i)"
|
||||
v-model="digits[i]"
|
||||
class="code-digit"
|
||||
size="large"
|
||||
maxlength="1"
|
||||
inputmode="numeric"
|
||||
:data-index="i"
|
||||
@input="onInput(i)"
|
||||
@keydown.delete="onDelete(i)"
|
||||
@paste.prevent="onPaste"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" size="large" style="width:100%;margin-top:24px"
|
||||
:loading="loading" :disabled="code.length < 6" @click="verify">
|
||||
验证并登录
|
||||
</el-button>
|
||||
|
||||
<div class="mfa-footer">
|
||||
<el-button link @click="backToLogin">返回登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Key } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const digits = ref(['', '', '', '', '', ''])
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const digitRefs = []
|
||||
|
||||
const code = computed(() => digits.value.join(''))
|
||||
|
||||
function setDigitRef(el, i) {
|
||||
if (el) digitRefs[i] = el
|
||||
}
|
||||
|
||||
// 输入处理:只允许数字,自动跳到下一格
|
||||
function onInput(i) {
|
||||
errorMsg.value = ''
|
||||
const val = digits.value[i]
|
||||
// 过滤非数字
|
||||
if (val && !/^\d$/.test(val)) {
|
||||
digits.value[i] = val.replace(/\D/g, '').slice(-1)
|
||||
}
|
||||
// 输入后自动跳到下一格
|
||||
if (digits.value[i] && i < 5) {
|
||||
nextTick(() => digitRefs[i + 1]?.focus?.())
|
||||
}
|
||||
// 全部填满自动验证
|
||||
if (code.value.length === 6) {
|
||||
verify()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除处理:当前格空时跳到上一格
|
||||
function onDelete(i) {
|
||||
if (!digits.value[i] && i > 0) {
|
||||
digitRefs[i - 1]?.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 粘贴处理:粘贴 6 位码自动分散到各格
|
||||
function onPaste(e) {
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '').slice(0, 6)
|
||||
if (text) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
digits.value[i] = text[i] || ''
|
||||
}
|
||||
if (text.length === 6) verify()
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (code.value.length < 6) return ElMessage.warning('请输入完整的 6 位验证码')
|
||||
|
||||
// 从 sessionStorage 读取暂存的登录凭据
|
||||
const pending = sessionStorage.getItem('pendingLogin')
|
||||
if (!pending) {
|
||||
ElMessage.warning('登录会话已过期,请重新登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const { username, password } = JSON.parse(pending)
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
// 重新提交完整登录请求(含 mfa_code),参考 opsx-web-lite 两步登录模式
|
||||
const { data } = await api.post('/login', { username, password, mfa_code: code.value })
|
||||
// 验证成功,清除暂存凭据
|
||||
sessionStorage.removeItem('pendingLogin')
|
||||
auth.login(data.token, data.username, data.role, data.must_change_password)
|
||||
ElMessage.success('验证成功')
|
||||
if (data.must_change_password) {
|
||||
router.push('/settings')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = e.response?.data?.error || '验证码错误,请重试'
|
||||
// 清空输入,聚焦第一格
|
||||
digits.value = ['', '', '', '', '', '']
|
||||
nextTick(() => digitRefs[0]?.focus?.())
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToLogin() {
|
||||
sessionStorage.removeItem('pendingLogin')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 没有暂存凭据则跳回登录页
|
||||
if (!sessionStorage.getItem('pendingLogin')) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
nextTick(() => digitRefs[0]?.focus?.())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mfa-container {
|
||||
height: 100dvh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--brand-grad); padding: var(--space-4);
|
||||
}
|
||||
.mfa-card {
|
||||
width: min(100%, 420px); background: var(--bg-card); border-radius: var(--radius-lg);
|
||||
padding: 40px 36px; box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.mfa-header { text-align: center; margin-bottom: var(--space-6); }
|
||||
.mfa-icon {
|
||||
width: 56px; height: 56px; border-radius: var(--radius-md); margin: 0 auto var(--space-4);
|
||||
background: var(--bg-inset); border: 1px solid var(--border-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mfa-header h2 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0 0 var(--space-2); }
|
||||
.mfa-header p { font-size: 14px; color: var(--text-secondary); margin: 0; }
|
||||
|
||||
.code-inputs {
|
||||
display: flex; justify-content: center; gap: var(--space-3);
|
||||
}
|
||||
.code-digit {
|
||||
width: 48px; flex-shrink: 0;
|
||||
}
|
||||
.code-digit :deep(.el-input__inner) {
|
||||
text-align: center; font-size: 22px; font-weight: 700; padding: 0;
|
||||
}
|
||||
|
||||
.mfa-footer { text-align: center; margin-top: var(--space-5); }
|
||||
</style>
|
||||
+179
-27
@@ -1,40 +1,111 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>中继节点</b>
|
||||
<el-button type="primary" :icon="Plus" @click="dialogVisible = true">添加节点</el-button>
|
||||
<div class="row-between">
|
||||
<b>节点管理</b>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">添加节点</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:14px"
|
||||
title="节点用于将专线部署到中继服务器。本机节点为默认节点;远程节点需在目标机部署 agent(规划中)。" />
|
||||
<el-table :data="nodes" size="large">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="address" label="地址" />
|
||||
<el-table-column label="类型" width="100">
|
||||
title="节点用于将专线部署到中继服务器。本机节点为内置默认节点;远程节点支持 SSH 凭证验证(密码/私钥),验证通过后状态显示为「已验证」。" />
|
||||
|
||||
<el-table :data="nodes" size="default" v-loading="loading">
|
||||
<el-table-column prop="name" label="名称" min-width="120">
|
||||
<template #default="{ row }"><b>{{ row.name }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地址" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-inline">{{ row.address }}:{{ row.port || 22 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.kind === 'local' ? 'success' : 'primary'" size="small">
|
||||
{{ row.kind === 'local' ? '本机' : '远程' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<el-table-column label="SSH 用户" width="100" prop="username" />
|
||||
<el-table-column label="认证方式" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.kind === 'local' ? '—' : ({ password: '密码', key: '私钥' }[row.auth_type] || '—') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.kind === 'local'" type="success" size="small">就绪</el-tag>
|
||||
<el-tag v-else-if="row.status === 'verified'" type="success" size="small">已验证</el-tag>
|
||||
<el-tag v-else-if="row.status === 'failed'" type="danger" size="small">验证失败</el-tag>
|
||||
<el-tag v-else type="info" size="small">未验证</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="120" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.kind !== 'local'" size="small" link :loading="verifyingId === row.id" @click="verify(row)">验证</el-button>
|
||||
<el-button v-if="row.kind !== 'local'" size="small" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.kind !== 'local'" type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有远程节点,点击右上角添加" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="添加节点" width="420px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="地址" required><el-input v-model="form.address" placeholder="agent 地址或 SSH 地址" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" /></el-form-item>
|
||||
<!-- 添加/编辑节点对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑节点' : '添加节点'" width="520px" align-center>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:北京中继节点" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" required>
|
||||
<el-input v-model="form.address" placeholder="IP 或域名,如 192.168.1.100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 端口">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="form.kind">
|
||||
<el-radio value="remote">远程节点</el-radio>
|
||||
<el-radio value="local">本机节点</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<template v-if="form.kind === 'remote'">
|
||||
<el-form-item label="SSH 用户">
|
||||
<el-input v-model="form.username" placeholder="默认 root" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证方式">
|
||||
<el-radio-group v-model="form.auth_type">
|
||||
<el-radio value="password">密码</el-radio>
|
||||
<el-radio value="key">私钥</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.auth_type === 'password'" :label="editing ? '新密码' : '密码'">
|
||||
<el-input v-model="form.password" type="password" show-password
|
||||
:placeholder="editing ? '留空表示不修改' : 'SSH 登录密码'" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.auth_type === 'key'" :label="editing ? '新私钥' : '私钥'">
|
||||
<el-input v-model="form.private_key" type="textarea" :rows="4"
|
||||
:placeholder="editing ? '留空表示不修改' : '粘贴 OpenSSH/PEM 格式私钥内容'" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 测试连接结果 -->
|
||||
<el-alert v-if="testResult" :type="testResult.success ? 'success' : 'error'"
|
||||
:title="testResult.success ? '连接成功' : '连接失败'"
|
||||
:description="testResult.message" show-icon :closable="false"
|
||||
style="margin-top:8px" />
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="form.kind === 'remote'" :loading="testing" @click="testConnection">
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="create">确定</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
@@ -42,28 +113,109 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const nodes = ref([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({ name: '', address: '', kind: 'remote', remark: '' })
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
const verifyingId = ref(null)
|
||||
|
||||
function emptyForm() {
|
||||
return { name: '', address: '', port: 22, kind: 'remote', username: 'root', auth_type: 'password', password: '', private_key: '', remark: '' }
|
||||
}
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.value = emptyForm()
|
||||
testResult.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
editing.value = row
|
||||
form.value = { ...row, password: '', private_key: '' }
|
||||
testResult.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/nodes')
|
||||
nodes.value = data || []
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/nodes')
|
||||
nodes.value = data || []
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '加载失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function create() {
|
||||
|
||||
async function testConnection() {
|
||||
if (!form.value.address) return ElMessage.warning('请先填写地址')
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const { data } = await api.post('/nodes/test', {
|
||||
address: form.value.address, port: form.value.port, username: form.value.username,
|
||||
auth_type: form.value.auth_type, password: form.value.password, private_key: form.value.private_key,
|
||||
})
|
||||
testResult.value = data
|
||||
} catch (e) {
|
||||
testResult.value = { success: false, message: e.response?.data?.error || '请求失败' }
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.address) return ElMessage.warning('请填写名称与地址')
|
||||
await api.post('/nodes', form.value)
|
||||
dialogVisible.value = false
|
||||
form.value = { name: '', address: '', kind: 'remote', remark: '' }
|
||||
load()
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await api.put(`/nodes/${editing.value.id}`, form.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await api.post('/nodes', form.value)
|
||||
ElMessage.success('已添加')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function verify(row) {
|
||||
verifyingId.value = row.id
|
||||
try {
|
||||
const { data } = await api.post(`/nodes/${row.id}/verify`)
|
||||
if (data.success) {
|
||||
ElMessage.success('验证成功:' + (data.message || '').slice(0, 50))
|
||||
} else {
|
||||
ElMessage.error('验证失败:' + (data.message || ''))
|
||||
}
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '验证失败')
|
||||
} finally { verifyingId.value = null }
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
await api.delete(`/nodes/${row.id}`)
|
||||
load()
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除节点「${row.name}」?该操作不可撤销。`, '删除确认', { type: 'warning' })
|
||||
await api.delete(`/nodes/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono-inline { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<el-result icon="warning" title="404" sub-title="您访问的页面不存在或已被移除">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex; align-items: center; justify-content: center; min-height: 60vh;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="row-between">
|
||||
<b>自定义协议插件</b>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">注册插件</el-button>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
<p style="margin:0">插件通过<b>命令钩子</b>实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。</p>
|
||||
<p style="margin:4px 0 0;font-size:12px">契约:<code>Create / Start / Stop / Status / Config</code>;Status 钩子 stdout 输出 <code>running</code> 或 <code>stopped</code>;Config 钩子 stdout 输出客户端配置文本。</p>
|
||||
</el-alert>
|
||||
<el-table :data="plugins" size="large">
|
||||
<el-table :data="plugins" size="default" v-loading="loading">
|
||||
<el-table-column prop="display_name" label="名称" width="160" />
|
||||
<el-table-column label="协议标识" width="140">
|
||||
<template #default="{ row }"><code>custom:{{ row.name }}</code></template>
|
||||
@@ -28,6 +28,7 @@
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="还没有自定义协议插件" /></template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
@@ -62,6 +63,7 @@ import { Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const plugins = ref([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = ref(emptyForm())
|
||||
@@ -73,8 +75,13 @@ function openCreate() { editing.value = null; form.value = emptyForm(); dialogVi
|
||||
function edit(row) { editing.value = row; form.value = { ...row }; dialogVisible.value = true }
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/plugins')
|
||||
plugins.value = data || []
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/plugins')
|
||||
plugins.value = data || []
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '加载失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.start_hook) return ElMessage.warning('协议标识与 Start 钩子必填')
|
||||
@@ -89,9 +96,14 @@ async function save() {
|
||||
}
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`删除插件「${row.display_name}」?`, '确认', { type: 'warning' })
|
||||
await api.delete(`/plugins/${row.id}`)
|
||||
load()
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除插件「${row.display_name}」?`, '确认', { type: 'warning' })
|
||||
await api.delete(`/plugins/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 在线会话 -->
|
||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<div>
|
||||
<b>在线会话</b>
|
||||
<el-tag size="small" type="success" style="margin-left:8px">{{ sessions.length }} 个在线</el-tag>
|
||||
</div>
|
||||
<el-button link @click="load">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="sessions" size="default" v-loading="loading">
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column label="协议" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ protocolDisplay(row.protocol) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="real_address" label="公网地址" min-width="140" />
|
||||
<el-table-column prop="virtual_address" label="VPN IP" min-width="120" />
|
||||
<el-table-column label="已连接时长" width="120">
|
||||
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流量 ↓ / ↑" width="140">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" link @click="disconnect(row)">踢下线</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前无在线用户" /></template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 异常流量告警 -->
|
||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length">
|
||||
<template #header><b style="color:var(--color-danger)">⚠ 异常流量告警</b></template>
|
||||
<el-table :data="alerts" size="small">
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户/隧道" width="100" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.event === 'rate_alert' ? 'warning' : 'danger'" size="small">
|
||||
{{ { rate_alert: '突发流量', quota_exceed: '配额超额' }[row.event] || row.event }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detail" label="详情" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 连接审计日志 -->
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>连接审计日志</b></template>
|
||||
<el-table :data="logs" size="small" max-height="500">
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户" width="100" />
|
||||
<el-table-column label="事件" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP 地址" width="140" />
|
||||
<el-table-column prop="detail" label="详情" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../api'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
|
||||
const sessions = ref([])
|
||||
const logs = ref([])
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + u[i]
|
||||
}
|
||||
function fmtDuration(t) {
|
||||
if (!t) return '-'
|
||||
const sec = Math.floor((Date.now() - new Date(t).getTime()) / 1000)
|
||||
if (sec < 60) return `${sec}秒`
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}分钟`
|
||||
return `${Math.floor(sec / 3600)}小时${Math.floor((sec % 3600) / 60)}分`
|
||||
}
|
||||
function eventLabel(e) {
|
||||
return { connect: '连接', disconnect: '断开', auth_fail: '认证失败', quota_exceed: '配额超额', rate_alert: '突发流量' }[e] || e
|
||||
}
|
||||
function eventTagType(e) {
|
||||
return { connect: 'success', disconnect: 'info', auth_fail: 'danger', quota_exceed: 'warning', rate_alert: 'warning' }[e] || 'info'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [s, l, a] = await Promise.all([
|
||||
api.get('/sessions'),
|
||||
api.get('/sessions/log'),
|
||||
api.get('/traffic/alerts'),
|
||||
])
|
||||
sessions.value = s.data || []
|
||||
logs.value = l.data || []
|
||||
alerts.value = a.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function disconnect(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定踢用户「${row.username}」下线?`, '确认', { type: 'warning' })
|
||||
await api.post(`/sessions/${row.id}/disconnect`)
|
||||
ElMessage.success('已踢下线')
|
||||
load()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(() => { load(); timer = setInterval(load, 15000) })
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
+110
-21
@@ -1,46 +1,75 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
|
||||
title="首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能"
|
||||
style="margin-bottom:16px" />
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>修改密码</b></template>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="原密码"><el-input v-model="pwd.old_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="新密码"><el-input v-model="pwd.new_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="确认新密码"><el-input v-model="pwd.confirm" type="password" show-password /></el-form-item>
|
||||
<el-button type="primary" style="width:100%" @click="changePwd">修改</el-button>
|
||||
<el-form label-width="90px" @submit.prevent="changePwd">
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="pwd.old_password" type="password" show-password autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password" placeholder="至少 8 位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input v-model="pwd.confirm" type="password" show-password autocomplete="new-password" @keyup.enter="changePwd" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" style="width:100%" :loading="pwdLoading" @click="changePwd">修改</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>双因素认证(MFA)</b></template>
|
||||
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。" style="margin-bottom:10px" />
|
||||
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。备用码可在丢失手机时登录。" style="margin-bottom:10px" />
|
||||
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">设置 MFA</el-button>
|
||||
<el-button v-else type="danger" @click="disableMFA">关闭 MFA</el-button>
|
||||
<div v-if="mfa.uri" style="margin-top:12px"><canvas ref="mfaCanvas"></canvas><el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" /><el-button type="success" style="margin-top:8px" @click="enableMFA">验证并启用</el-button></div>
|
||||
<div v-if="mfa.uri" style="margin-top:12px">
|
||||
<canvas ref="mfaCanvas" style="display:block;margin:0 auto"></canvas>
|
||||
<el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" maxlength="6" />
|
||||
<el-button type="success" style="width:100%;margin-top:8px" :loading="mfaLoading" @click="enableMFA">验证并启用</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>部署说明</b></template>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="交付形态">单二进制(前端已内嵌)或 Docker</el-descriptions-item>
|
||||
<el-descriptions-item label="交付形态">单二进制(前端已内嵌),无需 Docker</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">./data(可用 VPN_DATA_DIR 覆盖)</el-descriptions-item>
|
||||
<el-descriptions-item label="监听地址">:8080(VPN_LISTEN_ADDR 覆盖)</el-descriptions-item>
|
||||
<el-descriptions-item label="密钥存储">PSK / 私钥 AES-GCM 加密落库,配置文件 0600 权限</el-descriptions-item>
|
||||
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp(缺失时标记为未部署,无法启动)</el-descriptions-item>
|
||||
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp</el-descriptions-item>
|
||||
<el-descriptions-item label="生产建议">经 Nginx/Caddy 反向代理终止 TLS 后暴露</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>操作审计(最近 100 条)</b></template>
|
||||
<el-table :data="logs" size="small" max-height="560">
|
||||
<el-table :data="logs" size="small" max-height="560" v-loading="auditLoading">
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户" width="90" />
|
||||
<el-table-column prop="action" label="动作" width="140" />
|
||||
<el-table-column prop="target" label="对象" show-overflow-tooltip />
|
||||
<template #empty><el-empty description="暂无审计日志" /></template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- MFA 备用码持久化弹窗(P0 修复:不再用会消失的 Toast) -->
|
||||
<el-dialog v-model="backupVisible" title="MFA 已启用 — 请保存备用码" width="480px" align-center
|
||||
:close-on-click-modal="false" :close-on-press-escape="false">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px"
|
||||
title="这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!" />
|
||||
<div class="backup-codes">
|
||||
<code v-for="(c, i) in backupCodes" :key="i">{{ c }}</code>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="copyBackup">复制全部</el-button>
|
||||
<el-button type="primary" @click="backupVisible = false">我已保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
@@ -48,29 +77,89 @@
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||
const pwdLoading = ref(false)
|
||||
const logs = ref([])
|
||||
const auditLoading = ref(false)
|
||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||
const mfaCanvas = ref(null)
|
||||
const mfaLoading = ref(false)
|
||||
const backupVisible = ref(false)
|
||||
const backupCodes = ref([])
|
||||
|
||||
async function changePwd() {
|
||||
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning('两次新密码不一致')
|
||||
if (pwd.value.new_password.length < 8) return ElMessage.warning('新密码至少 8 位')
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
await api.post('/password', pwd.value)
|
||||
await api.post('/password', { old_password: pwd.value.old_password, new_password: pwd.value.new_password })
|
||||
auth.clearMustChange()
|
||||
ElMessage.success('密码已修改')
|
||||
pwd.value = { old_password: '', new_password: '', confirm: '' }
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '修改失败')
|
||||
}
|
||||
} finally { pwdLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [audit, status] = await Promise.all([api.get('/audit'), api.get('/mfa/status')])
|
||||
logs.value = audit.data || []
|
||||
mfa.value.enabled = status.data.enabled
|
||||
try {
|
||||
const status = await api.get('/mfa/status')
|
||||
mfa.value.enabled = status.data.enabled
|
||||
} catch { /* ignore */ }
|
||||
if (auth.isAdmin()) {
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const audit = await api.get('/audit')
|
||||
logs.value = audit.data || []
|
||||
} catch { /* viewer */ } finally { auditLoading.value = false }
|
||||
}
|
||||
})
|
||||
async function setupMFA() { const { data } = await api.post('/mfa/setup'); mfa.value.uri = data.otpauth_url; await nextTick(); QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 }) }
|
||||
async function enableMFA() { try { const { data } = await api.post('/mfa/enable', { code: mfa.value.code }); mfa.value = { enabled: true, uri: '', code: '' }; ElMessage.success(`MFA 已启用,请保存备用码:${data.backup_codes.join(' ')}`) } catch (e) { ElMessage.error(e.response?.data?.error || '验证失败') } }
|
||||
async function disableMFA() { await api.post('/mfa/disable'); mfa.value = { enabled: false, uri: '', code: '' }; ElMessage.success('MFA 已关闭') }
|
||||
|
||||
async function setupMFA() {
|
||||
try {
|
||||
const { data } = await api.post('/mfa/setup')
|
||||
mfa.value.uri = data.otpauth_url
|
||||
await nextTick()
|
||||
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 })
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || '设置失败') }
|
||||
}
|
||||
|
||||
async function enableMFA() {
|
||||
mfaLoading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/mfa/enable', { code: mfa.value.code })
|
||||
mfa.value = { enabled: true, uri: '', code: '' }
|
||||
// P0 修复:备用码用持久弹窗展示,不再用会消失的 Toast
|
||||
backupCodes.value = data.backup_codes || []
|
||||
backupVisible.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '验证失败')
|
||||
} finally { mfaLoading.value = false }
|
||||
}
|
||||
|
||||
async function disableMFA() {
|
||||
try {
|
||||
await api.post('/mfa/disable')
|
||||
mfa.value = { enabled: false, uri: '', code: '' }
|
||||
ElMessage.success('MFA 已关闭')
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || '关闭失败') }
|
||||
}
|
||||
|
||||
function copyBackup() {
|
||||
navigator.clipboard.writeText(backupCodes.value.join('\n')).then(() => ElMessage.success('已复制到剪贴板'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backup-codes {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;
|
||||
background: var(--bg-inset); border-radius: var(--radius-sm); border: 1px solid var(--border-base);
|
||||
}
|
||||
.backup-codes code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace; font-size: 14px; font-weight: 600;
|
||||
color: var(--text-primary); text-align: center; padding: 6px;
|
||||
background: var(--bg-card); border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -19,8 +19,9 @@
|
||||
</el-form-item>
|
||||
<el-form-item v-for="field in visibleFields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
|
||||
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
|
||||
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
|
||||
<el-option v-for="option in field.options" :key="option" :label="option" :value="option" />
|
||||
<el-option v-for="option in field.options" :key="optionValue(option)" :label="optionLabel(option)" :value="optionValue(option)" />
|
||||
</el-select>
|
||||
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
|
||||
show-password :placeholder="field.placeholder || '留空保持原值'" />
|
||||
@@ -45,7 +46,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../api'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay } from '../protocols'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -8,21 +8,30 @@
|
||||
|
||||
<!-- 第一步:协议选择 -->
|
||||
<div v-if="step === 0" class="proto-grid">
|
||||
<div v-for="(meta, key) in builtin" :key="key" class="proto-card" :class="{ active: form.protocol === key }"
|
||||
<div v-for="(meta, key) in builtin" :key="key"
|
||||
class="proto-card"
|
||||
:class="{ active: form.protocol === key, disabled: depStatus[key] && !depStatus[key].available }"
|
||||
@click="selectProtocol(key)">
|
||||
<div class="proto-icon">{{ meta.icon }}</div>
|
||||
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">推荐</el-tag>
|
||||
<div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div>
|
||||
<div class="proto-name" :style="{ color: meta.color }">{{ meta.name }}</div>
|
||||
<div class="proto-desc">{{ meta.desc }}</div>
|
||||
<div class="proto-scene">{{ meta.scenes }}</div>
|
||||
<div class="dep-badge">
|
||||
<el-tag v-if="depStatus[key]" :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
||||
{{ depStatus[key].available ? '依赖就绪' : '未部署' }}
|
||||
<div class="dep-badge" v-if="depStatus[key]">
|
||||
<el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
||||
{{ depStatus[key].available ? '✓ 依赖就绪' : '未部署' }}
|
||||
</el-tag>
|
||||
<!-- 未就绪:显示下载/安装操作 -->
|
||||
<div v-if="!depStatus[key].available" class="dep-action" @click.stop>
|
||||
<el-button v-if="canDeploy(key)" size="small" type="primary" :loading="deploying === key"
|
||||
@click="deployProtocol(key)">下载部署</el-button>
|
||||
<el-button v-else size="small" @click="showInstallCmd(key)">安装命令</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
|
||||
@click="selectProtocol(p.name)">
|
||||
<div class="proto-icon">🧩</div>
|
||||
<div class="proto-icon"><ProtocolIcon protocol="custom" :size="36" /></div>
|
||||
<div class="proto-name" style="color:#7b5ea7">{{ p.display_name }}</div>
|
||||
<div class="proto-desc">自定义协议插件</div>
|
||||
<div class="proto-scene">自定义对接</div>
|
||||
@@ -39,16 +48,37 @@
|
||||
<el-tag :color="currentColor" effect="dark" style="border:none">{{ currentName }}</el-tag>
|
||||
<el-button link style="margin-left:10px" @click="step = 0">重新选择</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="f in visibleFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<!-- 基础字段 -->
|
||||
<el-form-item v-for="f in coreFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
|
||||
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
|
||||
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
|
||||
<el-option v-for="o in f.options" :key="o" :label="o" :value="o" />
|
||||
<el-option v-for="o in f.options" :key="optionValue(o)" :label="optionLabel(o)" :value="optionValue(o)" />
|
||||
</el-select>
|
||||
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
|
||||
show-password :placeholder="f.placeholder" />
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ f.tip }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 高级字段(可折叠) -->
|
||||
<template v-if="advancedFields.length">
|
||||
<el-collapse v-model="advCollapsed" style="margin-top:8px">
|
||||
<el-collapse-item title="高级选项" name="adv">
|
||||
<el-form-item v-for="f in advancedFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
|
||||
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
|
||||
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
|
||||
<el-option v-for="o in f.options" :key="optionValue(o)" :label="optionLabel(o)" :value="optionValue(o)" />
|
||||
</el-select>
|
||||
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
|
||||
show-password :placeholder="f.placeholder" />
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ f.tip }}</div>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="form.expire_at" type="datetime" placeholder="留空为永久" style="width:100%" />
|
||||
</el-form-item>
|
||||
@@ -78,6 +108,33 @@
|
||||
</div>
|
||||
<canvas ref="qrCanvas" style="display:block;margin:16px auto" v-show="qrShown"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 安装命令弹窗(系统包协议) -->
|
||||
<el-dialog v-model="installCmdVisible" :title="`${installCmdTitle} 安装命令`" width="560px" align-center>
|
||||
<el-alert :type="installData.detected ? 'success' : 'warning'" :closable="false" style="margin-bottom:16px"
|
||||
:title="installData.detected
|
||||
? `检测到当前系统包管理器:${installData.pm},推荐使用以下命令`
|
||||
: '未检测到系统包管理器,以下是各平台安装命令供参考'" />
|
||||
<!-- 当前系统推荐命令 -->
|
||||
<div v-if="installData.command" style="margin-bottom:16px">
|
||||
<div class="cmd-label">
|
||||
<el-tag size="small" type="success">{{ installData.pm || 'apt (默认)' }}</el-tag>
|
||||
<span style="margin-left:8px;font-size:13px;color:var(--text-secondary)">推荐</span>
|
||||
</div>
|
||||
<pre class="mono conf-box" style="border-color:var(--color-success)">{{ installData.command }}</pre>
|
||||
<el-button size="small" style="margin-top:4px" @click="copyText(installData.command)">复制此命令</el-button>
|
||||
</div>
|
||||
<!-- 其他平台命令 -->
|
||||
<el-divider content-position="left"><span style="font-size:12px;color:var(--text-secondary)">其他发行版</span></el-divider>
|
||||
<div v-for="(cmd, pm) in installData.all_commands" :key="pm" class="cmd-row" v-show="cmd !== installData.command">
|
||||
<el-tag size="small" style="min-width:70px;text-align:center">{{ pm }}</el-tag>
|
||||
<code class="cmd-code">{{ cmd }}</code>
|
||||
<el-button link size="small" @click="copyText(cmd)">复制</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="installCmdVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
@@ -86,7 +143,8 @@ import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor } from '../protocols'
|
||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
|
||||
|
||||
const step = ref(0)
|
||||
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
|
||||
@@ -110,6 +168,16 @@ const visibleFields = computed(() => currentFields.value.filter(f => {
|
||||
? v.includes(form.value.config[k])
|
||||
: form.value.config[k] === v)
|
||||
}))
|
||||
// 必填字段为"基础",其余为"高级"(可折叠),超过 6 个字段才拆分
|
||||
const coreFields = computed(() => {
|
||||
if (visibleFields.value.length <= 6) return visibleFields.value
|
||||
return visibleFields.value.filter(f => f.required)
|
||||
})
|
||||
const advancedFields = computed(() => {
|
||||
if (visibleFields.value.length <= 6) return []
|
||||
return visibleFields.value.filter(f => !f.required)
|
||||
})
|
||||
const advCollapsed = ref([])
|
||||
|
||||
function selectProtocol(key) {
|
||||
form.value.protocol = key
|
||||
@@ -123,6 +191,15 @@ function selectProtocol(key) {
|
||||
|
||||
async function create() {
|
||||
if (!form.value.name) return ElMessage.warning('请填写专线名称')
|
||||
// 必填字段校验:提示具体缺哪个字段
|
||||
for (const f of visibleFields.value) {
|
||||
if (f.required) {
|
||||
const val = form.value.config[f.key]
|
||||
if (val === undefined || val === '' || val === null) {
|
||||
return ElMessage.warning(`请填写必填项:${f.label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await api.post('/tunnels', {
|
||||
@@ -158,12 +235,77 @@ async function showQr() {
|
||||
QRCode.toCanvas(qrCanvas.value, clientConfig.value, { width: 260, margin: 1 })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/protocols')
|
||||
for (const p of data) {
|
||||
if (p.custom) customProtocols.value.push({ name: p.name, display_name: p.display_name })
|
||||
else depStatus.value[p.name] = p
|
||||
// ---- 依赖部署逻辑 ----
|
||||
const deploying = ref('')
|
||||
const installCmdVisible = ref(false)
|
||||
const installCmdTitle = ref('')
|
||||
const installData = ref({ command: '', pm: '', detected: false, all_commands: {} })
|
||||
|
||||
// 协议 → 二进制包名映射(用于判断是否支持平台下载)
|
||||
const deployableProtocols = ['frp', 'openvpn']
|
||||
|
||||
// IPSec/IKEv2/L2TP 归入 strongswan
|
||||
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
|
||||
|
||||
function canDeploy(protocol) {
|
||||
return deployableProtocols.includes(protocol)
|
||||
}
|
||||
|
||||
// 下载部署(FRP / OpenVPN 从 GitHub 下载)
|
||||
async function deployProtocol(protocol) {
|
||||
const pkg = protocolToPkg[protocol] || protocol
|
||||
deploying.value = protocol
|
||||
try {
|
||||
const { data } = await api.post(`/binaries/install/${pkg}`)
|
||||
ElMessage.success(`${protocol} ${data.version} 部署成功`)
|
||||
// 刷新依赖状态
|
||||
await refreshDepStatus()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || `${protocol} 部署失败`)
|
||||
} finally { deploying.value = '' }
|
||||
}
|
||||
|
||||
// 显示系统包安装命令
|
||||
async function showInstallCmd(protocol) {
|
||||
const pkg = protocolToPkg[protocol] || protocol
|
||||
installCmdTitle.value = protocol
|
||||
try {
|
||||
const { data } = await api.get(`/binaries/install-hint/${pkg}`)
|
||||
installData.value = {
|
||||
command: data.command || '',
|
||||
pm: data.pm || '',
|
||||
detected: data.detected || false,
|
||||
all_commands: data.all_commands || {},
|
||||
}
|
||||
} catch {
|
||||
installData.value = { command: '', pm: '', detected: false, all_commands: {} }
|
||||
}
|
||||
installCmdVisible.value = true
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
// 刷新协议依赖状态
|
||||
async function refreshDepStatus() {
|
||||
try {
|
||||
const { data } = await api.get('/protocols')
|
||||
for (const p of data) {
|
||||
if (!p.custom) depStatus.value[p.name] = p
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshDepStatus()
|
||||
// 加载自定义协议
|
||||
try {
|
||||
const { data } = await api.get('/protocols')
|
||||
for (const p of data) {
|
||||
if (p.custom) customProtocols.value.push({ name: p.name, display_name: p.display_name })
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -175,11 +317,19 @@ onMounted(async () => {
|
||||
}
|
||||
.proto-card:hover { border-color: #4a6fa5; transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,.08); }
|
||||
.proto-card.active { border-color: #4a6fa5; background: #f0f5fc; }
|
||||
.proto-icon { font-size: 34px; }
|
||||
.proto-card.disabled { opacity: 0.65; }
|
||||
.proto-card.disabled:hover { border-color: #e8eaee; transform: none; box-shadow: none; }
|
||||
.proto-card { position: relative; }
|
||||
.recommend-badge { position: absolute; top: 8px; right: 8px; }
|
||||
.dep-action { margin-top: 8px; }
|
||||
.proto-icon { display: flex; justify-content: center; align-items: center; height: 40px; margin-bottom: 4px; }
|
||||
.proto-name { font-size: 16px; font-weight: 700; margin: 8px 0 4px; }
|
||||
.proto-desc { font-size: 12px; color: #606266; min-height: 34px; }
|
||||
.proto-scene { font-size: 11px; color: #909399; margin-top: 6px; }
|
||||
.dep-badge { margin-top: 8px; }
|
||||
.field-tip { font-size: 12px; color: #909399; line-height: 1.4; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 14px; max-height: 320px; overflow: auto; }
|
||||
.field-tip { font-size: 12px; color: var(--text-secondary); line-height: 1.4; }
|
||||
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 14px; max-height: 320px; overflow: auto; }
|
||||
.cmd-label { display: flex; align-items: center; margin-bottom: 6px; }
|
||||
.cmd-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.cmd-code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; flex: 1; background: var(--bg-inset); padding: 4px 8px; border-radius: 4px; }
|
||||
</style>
|
||||
+342
-15
@@ -7,6 +7,11 @@
|
||||
<el-select v-model="protocolFilter" placeholder="全部协议" clearable style="width:160px;margin-left:10px" @change="load">
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
|
||||
</el-select>
|
||||
<el-select v-model="stateFilter" placeholder="全部状态" clearable style="width:140px;margin-left:10px" @change="load">
|
||||
<el-option label="运行中" value="running" />
|
||||
<el-option label="已停止" value="stopped" />
|
||||
<el-option label="异常" value="error" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建专线</el-button>
|
||||
</div>
|
||||
@@ -19,11 +24,12 @@
|
||||
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" width="130">
|
||||
<el-table-column label="协议" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="protocolColor(row.protocol)" effect="dark" style="border:none">
|
||||
<span class="proto-cell">
|
||||
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
|
||||
{{ protocolDisplay(row.protocol) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
@@ -49,24 +55,35 @@
|
||||
<el-table-column label="节点" width="110">
|
||||
<template #default="{ row }">{{ row.node?.name || '本机节点' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">启动</el-button>
|
||||
<el-button v-else type="warning" size="small" link @click="stop(row)">停止</el-button>
|
||||
<el-button size="small" link @click="showDetail(row)">详情</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">编辑</el-button>
|
||||
<el-button size="small" link @click="download(row)">下载配置</el-button>
|
||||
<el-button v-if="row.protocol === 'wireguard'" size="small" link @click="showQr(row)">二维码</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
||||
<el-button size="small" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logs">查看日志</el-dropdown-item>
|
||||
<el-dropdown-item command="download">下载配置</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">显示二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">IPSec 对接测试</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button type="danger" size="small" link style="margin-left:12px" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有专线,点击右上角创建第一条" />
|
||||
<el-empty description="还没有专线">
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建第一条专线</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="520px">
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="协议">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
|
||||
@@ -78,7 +95,64 @@
|
||||
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ detail.client_config || '该协议暂无客户端配置' }}</pre>
|
||||
<el-button type="primary" style="width:100%" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
|
||||
@click="$router.push('/ipsec-peer')">IPSec 参数检查与连通性测试</el-button>
|
||||
|
||||
<!-- 流量策略 -->
|
||||
<h4>流量策略</h4>
|
||||
<el-form label-width="90px" size="small" v-loading="policyLoading">
|
||||
<el-form-item label="配额(GB)">
|
||||
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
|
||||
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">0 = 不限。已用:{{ fmtBytes(policy.used_bytes || 0) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="限速(Mbps)">
|
||||
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
|
||||
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">0 = 不限</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="超额动作">
|
||||
<el-select v-model="policy.action_on_exceed" style="width:100%">
|
||||
<el-option value="warn" label="仅告警" />
|
||||
<el-option value="block" label="自动停止" />
|
||||
<el-option value="throttle" label="限速至1Mbps" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="重置周期">
|
||||
<el-select v-model="policy.reset_cycle" style="width:100%">
|
||||
<el-option value="none" label="不重置" />
|
||||
<el-option value="daily" label="每天" />
|
||||
<el-option value="monthly" label="每月" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">保存策略</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- WireGuard Peer 管理 -->
|
||||
<template v-if="detail.tunnel.protocol === 'wireguard'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
|
||||
<h4 style="margin:0">Peer 列表</h4>
|
||||
<el-button type="primary" size="small" @click="openAddPeer">添加 Peer</el-button>
|
||||
</div>
|
||||
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="name" label="名称" width="100" />
|
||||
<el-table-column prop="client_address" label="地址" min-width="110" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
|
||||
{{ peerOnline(row) ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">配置</el-button>
|
||||
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">二维码</el-button>
|
||||
<el-button type="danger" link size="small" @click="removePeer(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
@@ -89,26 +163,80 @@
|
||||
<p style="color:#909399;font-size:12px">使用 WireGuard App「从二维码添加」</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加 Peer -->
|
||||
<el-dialog v-model="peerDialog" title="添加 WireGuard Peer" width="440px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="Peer ID">
|
||||
<el-input v-model="peerForm.id" placeholder="留空自动生成 client-N" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="peerForm.name" placeholder="手机 / 笔记本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户端地址" required>
|
||||
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AllowedIPs">
|
||||
<el-input v-model="peerForm.allowed_ips" placeholder="留空使用隧道默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部公钥">
|
||||
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
|
||||
placeholder="留空则平台托管生成密钥;填写则为外部设备" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="peerDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="peerSaving" @click="addPeer">添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志实时查看弹窗 -->
|
||||
<el-dialog v-model="logVisible" :title="`实例日志 — ${logTunnel?.name || ''}`" width="780px" align-center
|
||||
@close="closeLogStream">
|
||||
<div class="log-toolbar">
|
||||
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
|
||||
{{ logStreaming ? '● 实时输出中' : '已停止' }}
|
||||
</el-tag>
|
||||
<el-button v-if="logStreaming" size="small" @click="stopLogStream">暂停</el-button>
|
||||
<el-button v-else size="small" type="primary" @click="resumeLogStream">继续</el-button>
|
||||
<el-button size="small" @click="logContent = ''">清屏</el-button>
|
||||
</div>
|
||||
<div class="log-view mono" ref="logView">{{ logContent || '等待日志输出...' }}</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { Plus, InfoFilled, ArrowDown } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '../api'
|
||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||
import { protocolDisplay, protocolColor } from '../protocols'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const protocolFilter = ref('')
|
||||
const stateFilter = ref('')
|
||||
const protocolOptions = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const qrVisible = ref(false)
|
||||
// 流量策略
|
||||
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
|
||||
const policyLoading = ref(false)
|
||||
const policySaving = ref(false)
|
||||
const qrCanvas = ref(null)
|
||||
const wgPeers = ref([])
|
||||
const runtimePeers = ref([])
|
||||
const peersLoading = ref(false)
|
||||
const peerDialog = ref(false)
|
||||
const peerSaving = ref(false)
|
||||
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
@@ -134,15 +262,114 @@ function stateOf(row) { return row.runtime?.state || row.state }
|
||||
function prettyConfig(c) {
|
||||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||||
}
|
||||
function peerOnline(row) {
|
||||
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
|
||||
return !!rt?.connected
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||||
list.value = data || []
|
||||
let result = data || []
|
||||
// 前端状态筛选(后端无 state 参数)
|
||||
if (stateFilter.value) {
|
||||
result = result.filter(r => stateOf(r) === stateFilter.value)
|
||||
}
|
||||
list.value = result
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 更多操作下拉命令分发
|
||||
function onMoreCommand(cmd, row) {
|
||||
if (cmd === 'download') download(row)
|
||||
else if (cmd === 'qrcode') showQr(row)
|
||||
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
|
||||
else if (cmd === 'logs') openLogStream(row)
|
||||
}
|
||||
|
||||
// ---- 实时日志流(SSE)----
|
||||
const logVisible = ref(false)
|
||||
const logTunnel = ref(null)
|
||||
const logContent = ref('')
|
||||
const logStreaming = ref(false)
|
||||
const logView = ref(null)
|
||||
let logEventSource = null
|
||||
|
||||
function openLogStream(row) {
|
||||
logTunnel.value = row
|
||||
logContent.value = ''
|
||||
logVisible.value = true
|
||||
resumeLogStream()
|
||||
}
|
||||
|
||||
function resumeLogStream() {
|
||||
if (!logTunnel.value) return
|
||||
stopLogStream()
|
||||
const token = localStorage.getItem('vpn_token')
|
||||
// SSE 不支持自定义 header,通过 query 传 token
|
||||
logEventSource = new EventSource(`/api/tunnels/${logTunnel.value.id}/logs?token=${token}`)
|
||||
logStreaming.value = true
|
||||
logEventSource.onmessage = (e) => {
|
||||
logContent.value += e.data + '\n'
|
||||
// 自动滚动到底部
|
||||
nextTick(() => {
|
||||
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
|
||||
})
|
||||
// 限制日志长度防止内存爆炸
|
||||
if (logContent.value.length > 100000) {
|
||||
logContent.value = logContent.value.slice(-50000)
|
||||
}
|
||||
}
|
||||
logEventSource.onerror = () => {
|
||||
logStreaming.value = false
|
||||
logEventSource?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function stopLogStream() {
|
||||
logStreaming.value = false
|
||||
logEventSource?.close()
|
||||
logEventSource = null
|
||||
}
|
||||
|
||||
function closeLogStream() {
|
||||
stopLogStream()
|
||||
logContent.value = ''
|
||||
}
|
||||
|
||||
// ---- 流量策略 ----
|
||||
async function loadPolicy(tunnelId) {
|
||||
policyLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
|
||||
policy.value = {
|
||||
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
|
||||
rate_limit_mbps: data.rate_limit_mbps || 0,
|
||||
action_on_exceed: data.action_on_exceed || 'block',
|
||||
reset_cycle: data.reset_cycle || 'none',
|
||||
used_bytes: data.used_bytes || 0,
|
||||
}
|
||||
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
|
||||
finally { policyLoading.value = false }
|
||||
}
|
||||
|
||||
async function savePolicy(tunnelId) {
|
||||
policySaving.value = true
|
||||
try {
|
||||
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
await api.put(`/tunnels/${tunnelId}/policy`, {
|
||||
quota_bytes: quotaBytes,
|
||||
rate_limit_mbps: policy.value.rate_limit_mbps,
|
||||
action_on_exceed: policy.value.action_on_exceed,
|
||||
reset_cycle: policy.value.reset_cycle,
|
||||
})
|
||||
ElMessage.success('流量策略已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
} finally { policySaving.value = false }
|
||||
}
|
||||
|
||||
async function start(row) {
|
||||
try {
|
||||
await api.post(`/tunnels/${row.id}/start`)
|
||||
@@ -167,9 +394,103 @@ async function showDetail(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
if (row.protocol === 'wireguard') {
|
||||
await loadPeers(row.id)
|
||||
} else {
|
||||
wgPeers.value = []
|
||||
runtimePeers.value = []
|
||||
}
|
||||
loadPolicy(row.id)
|
||||
}
|
||||
|
||||
async function loadPeers(tunnelId) {
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
|
||||
wgPeers.value = data.peers || []
|
||||
runtimePeers.value = data.runtime_peers || []
|
||||
} catch {
|
||||
wgPeers.value = []
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAddPeer() {
|
||||
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
|
||||
peerDialog.value = true
|
||||
}
|
||||
|
||||
async function addPeer() {
|
||||
if (!peerForm.value.client_address) return ElMessage.warning('请填写客户端地址')
|
||||
peerSaving.value = true
|
||||
try {
|
||||
const body = { ...peerForm.value }
|
||||
if (!body.id) delete body.id
|
||||
if (!body.public_key) delete body.public_key
|
||||
if (!body.allowed_ips) delete body.allowed_ips
|
||||
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
|
||||
ElMessage.success('Peer 已添加')
|
||||
peerDialog.value = false
|
||||
await loadPeers(detail.value.tunnel.id)
|
||||
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
|
||||
detail.value = data
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '添加失败')
|
||||
} finally {
|
||||
peerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePeer(row) {
|
||||
await ElMessageBox.confirm(`删除 Peer「${row.id}」?`, '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadPeers(detail.value.tunnel.id)
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPeer(row) {
|
||||
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||||
}).then(async r => {
|
||||
if (!r.ok) {
|
||||
const d = await r.json()
|
||||
throw new Error(d.error)
|
||||
}
|
||||
return r.blob()
|
||||
}).then(b => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(b)
|
||||
a.download = `wireguard-${row.id}.conf`
|
||||
a.click()
|
||||
}).catch(e => ElMessage.error(e.message || '下载失败'))
|
||||
}
|
||||
|
||||
async function qrPeer(row) {
|
||||
try {
|
||||
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json()
|
||||
throw new Error(d.error)
|
||||
}
|
||||
const conf = await res.text()
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成二维码失败')
|
||||
}
|
||||
}
|
||||
|
||||
function download(row) {
|
||||
// 带鉴权下载
|
||||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
@@ -199,6 +520,12 @@ onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remark { font-size: 12px; color: #909399; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 12px; max-height: 260px; overflow: auto; }
|
||||
.remark { font-size: 12px; color: var(--text-secondary); }
|
||||
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
|
||||
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
|
||||
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.log-view {
|
||||
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
|
||||
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
+278
-26
@@ -1,23 +1,55 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="15">
|
||||
<el-col :xs="24" :lg="15">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><div style="display:flex;justify-content:space-between"><b>管理用户</b><el-button type="primary" @click="openCreate">添加本地用户</el-button></div></template>
|
||||
<el-table :data="users" size="small">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="来源" width="90"><template #default="{row}"><el-tag size="small">{{ row.source }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="角色" width="100"><template #default="{row}"><el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'operator' ? 'warning' : 'info'" size="small">{{ row.role }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="130"><template #default="{row}"><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button><el-button v-if="row.username !== auth.username" type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<b>管理用户</b>
|
||||
<div>
|
||||
<el-button @click="batchVisible = true">批量导入</el-button>
|
||||
<el-button @click="ldapImportVisible = true">LDAP 导入</el-button>
|
||||
<el-button type="primary" @click="openCreate">添加用户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="users" size="small" v-loading="loading">
|
||||
<el-table-column prop="username" label="用户名" min-width="100" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="140" />
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.source === 'ldap' ? 'primary' : 'info'" size="small">{{ row.source === 'ldap' ? 'LDAP' : '本地' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'operator' ? 'warning' : 'info'" size="small">
|
||||
{{ { admin: '管理员', operator: '运维', viewer: '只读' }[row.role] || row.role }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
|
||||
<el-button link size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="row.username !== auth.username" type="danger" link size="small" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="还没有用户" /></template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
|
||||
<el-col :xs="24" :lg="9">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>LDAP 身份源</b></template>
|
||||
<el-alert type="info" :closable="false" title="LDAP 用户首次登录后同步为只读管理用户;可由管理员调整角色。" style="margin-bottom:12px" />
|
||||
<el-form label-width="88px" size="small">
|
||||
<el-alert type="info" :closable="false"
|
||||
title="LDAP 用户可首次登录后自动同步,也可使用左侧「LDAP 导入」预先添加。" style="margin-bottom:12px" />
|
||||
<el-form label-width="90px" size="small">
|
||||
<el-form-item label="启用"><el-switch v-model="ldap.enabled" /></el-form-item>
|
||||
<el-form-item label="LDAP URL"><el-input v-model="ldap.url" placeholder="ldaps://ldap.example.com:636" /></el-form-item>
|
||||
<el-form-item label="Bind DN"><el-input v-model="ldap.bind_dn" /></el-form-item>
|
||||
@@ -25,29 +57,249 @@
|
||||
<el-form-item label="Base DN"><el-input v-model="ldap.base_dn" placeholder="dc=example,dc=com" /></el-form-item>
|
||||
<el-form-item label="用户过滤"><el-input v-model="ldap.user_filter" placeholder="(objectClass=inetOrgPerson)" /></el-form-item>
|
||||
<el-form-item label="用户名属性"><el-input v-model="ldap.username_attribute" placeholder="uid" /></el-form-item>
|
||||
<el-button type="primary" @click="saveLDAP">保存</el-button><el-button @click="testLDAP">测试连接</el-button>
|
||||
<div style="text-align:right">
|
||||
<el-button @click="testLDAP">测试连接</el-button>
|
||||
<el-button type="primary" @click="saveLDAP">保存</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-dialog v-model="visible" title="添加本地管理用户" width="420px">
|
||||
<el-form label-width="80px"><el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" show-password /></el-form-item><el-form-item label="角色"><el-select v-model="form.role"><el-option value="admin" label="管理员" /><el-option value="operator" label="运维员" /><el-option value="viewer" label="只读" /></el-select></el-form-item></el-form>
|
||||
<template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template>
|
||||
|
||||
<!-- 单用户新增/编辑对话框 -->
|
||||
<el-dialog v-model="visible" :title="editing ? '编辑用户' : '添加用户'" width="440px" align-center>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" :disabled="!!editing" placeholder="登录用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" placeholder="user@example.com" /></el-form-item>
|
||||
<el-form-item v-if="!editing || form.source === 'local'" label="密码" :required="!editing">
|
||||
<el-input v-model="form.password" type="password" show-password
|
||||
:placeholder="editing ? '留空不修改' : '至少 8 位'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width:100%">
|
||||
<el-option value="admin" label="管理员" />
|
||||
<el-option value="operator" label="运维" />
|
||||
<el-option value="viewer" label="只读" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量导入对话框 -->
|
||||
<el-dialog v-model="batchVisible" title="批量导入用户" width="600px" align-center>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px"
|
||||
title="每行一个用户,格式:用户名,密码 或仅用户名(自动生成密码)。密码至少 8 位。" />
|
||||
<el-input v-model="batchText" type="textarea" :rows="8"
|
||||
placeholder="每行一个:用户名,密码(如 user1,Pass1234);或仅用户名自动生成密码" />
|
||||
<el-form-item label="默认角色" style="margin-top:16px">
|
||||
<el-select v-model="batchRole" style="width:160px">
|
||||
<el-option value="viewer" label="只读" />
|
||||
<el-option value="operator" label="运维" />
|
||||
<el-option value="admin" label="管理员" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template #footer>
|
||||
<el-button @click="batchVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchLoading" @click="doBatchImport">开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- LDAP 导入对话框 -->
|
||||
<el-dialog v-model="ldapImportVisible" title="从 LDAP 导入用户" width="680px" align-center>
|
||||
<el-alert v-if="!ldap.enabled" type="warning" :closable="false" style="margin-bottom:12px"
|
||||
title="LDAP 未启用,请先在右侧配置并测试连接" />
|
||||
<div class="row-between" style="margin-bottom:12px">
|
||||
<span style="font-size:14px;color:var(--text-secondary)">
|
||||
<el-button v-if="ldap.enabled" link type="primary" :loading="ldapLoading" @click="loadLDAPUsers">刷新用户列表</el-button>
|
||||
</span>
|
||||
<span v-if="ldapUsers.length" style="font-size:13px;color:var(--text-secondary)">
|
||||
共 {{ ldapUsers.length }} 个用户,已选 {{ selectedDNs.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="ldapUsers" size="small" max-height="380" v-loading="ldapLoading"
|
||||
@selection-change="rows => selectedDNs = rows.map(r => r.dn)">
|
||||
<el-table-column type="selection" width="40" :selectable="row => !row.imported" />
|
||||
<el-table-column prop="uid" label="用户名" width="120" />
|
||||
<el-table-column prop="cn" label="姓名" min-width="100" />
|
||||
<el-table-column prop="mail" label="邮箱" min-width="160" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.imported ? 'success' : 'info'" size="small">{{ row.imported ? '已导入' : '待导入' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="ldap.enabled ? '点击上方刷新加载 LDAP 用户' : 'LDAP 未启用'" />
|
||||
</template>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="ldapImportVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importLoading" :disabled="!selectedDNs.length" @click="doLDAPImport">
|
||||
导入选中 ({{ selectedDNs.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api, { auth } from '../api'
|
||||
const users = ref([]), visible = ref(false)
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const visible = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = ref({ username: '', email: '', password: '', role: 'viewer', enabled: true })
|
||||
const ldap = ref({ enabled: false, url: '', bind_dn: '', bind_password: '', base_dn: '', user_filter: '(objectClass=inetOrgPerson)', username_attribute: 'uid' })
|
||||
async function load () { const [u, l] = await Promise.all([api.get('/users'), api.get('/ldap')]); users.value = u.data || []; ldap.value = { ...ldap.value, ...l.data, bind_password: '' } }
|
||||
function openCreate () { form.value = { username: '', email: '', password: '', role: 'viewer', enabled: true }; visible.value = true }
|
||||
async function create () { try { await api.post('/users', form.value); visible.value = false; ElMessage.success('用户已创建'); load() } catch (e) { ElMessage.error(e.response?.data?.error || '创建失败') } }
|
||||
async function toggle (row) { await api.put(`/users/${row.id}`, { enabled: !row.enabled }); load() }
|
||||
async function remove (row) { await ElMessageBox.confirm(`删除用户 ${row.username}?`, '确认'); await api.delete(`/users/${row.id}`); load() }
|
||||
async function saveLDAP () { try { await api.put('/ldap', ldap.value); ldap.value.bind_password = ''; ElMessage.success('LDAP 配置已保存') } catch (e) { ElMessage.error(e.response?.data?.error || '保存失败') } }
|
||||
async function testLDAP () { try { await api.post('/ldap/test'); ElMessage.success('LDAP 连接成功') } catch (e) { ElMessage.error(e.response?.data?.error || '连接失败') } }
|
||||
const ldap = ref({
|
||||
enabled: false, url: '', bind_dn: '', bind_password: '',
|
||||
base_dn: '', user_filter: '(objectClass=inetOrgPerson)', username_attribute: 'uid',
|
||||
start_tls: false,
|
||||
})
|
||||
|
||||
// 批量导入
|
||||
const batchVisible = ref(false)
|
||||
const batchText = ref('')
|
||||
const batchRole = ref('viewer')
|
||||
const batchLoading = ref(false)
|
||||
|
||||
// LDAP 导入
|
||||
const ldapImportVisible = ref(false)
|
||||
const ldapUsers = ref([])
|
||||
const ldapLoading = ref(false)
|
||||
const selectedDNs = ref([])
|
||||
const importLoading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [u, l] = await Promise.all([api.get('/users'), api.get('/ldap')])
|
||||
users.value = u.data || []
|
||||
if (l.data) {
|
||||
ldap.value = { ...ldap.value, ...l.data, bind_password: '' }
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '加载失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
// ---- 单用户 CRUD ----
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.value = { username: '', email: '', password: '', role: 'viewer', enabled: true }
|
||||
visible.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
editing.value = row
|
||||
form.value = { username: row.username, email: row.email, password: '', role: row.role, enabled: row.enabled, source: row.source }
|
||||
visible.value = true
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
if (editing.value) {
|
||||
const payload = { email: form.value.email, role: form.value.role }
|
||||
if (form.value.password) payload.password = form.value.password
|
||||
await api.put(`/users/${editing.value.id}`, payload)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
if (!form.value.username) return ElMessage.warning('请填写用户名')
|
||||
if (!form.value.password || form.value.password.length < 8) return ElMessage.warning('密码至少 8 位')
|
||||
await api.post('/users', form.value)
|
||||
ElMessage.success('用户已创建')
|
||||
}
|
||||
visible.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '操作失败')
|
||||
}
|
||||
}
|
||||
async function toggle(row) {
|
||||
await api.put(`/users/${row.id}`, { enabled: !row.enabled })
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户「${row.username}」?`, '删除确认', { type: 'warning' })
|
||||
await api.delete(`/users/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error(e.response?.data?.error || '删除失败') }
|
||||
}
|
||||
|
||||
// ---- 批量导入 ----
|
||||
async function doBatchImport() {
|
||||
const lines = batchText.value.trim().split('\n').filter(l => l.trim())
|
||||
if (!lines.length) return ElMessage.warning('请输入至少一行用户数据')
|
||||
const usersToCreate = lines.map(line => {
|
||||
const parts = line.trim().split(/[,,\t]/)
|
||||
const username = parts[0]?.trim()
|
||||
let password = parts[1]?.trim()
|
||||
// 仅用户名时自动生成 8 位随机密码
|
||||
if (!password) password = Math.random().toString(36).slice(2, 10)
|
||||
return { username, password, email: `${username}@local`, role: batchRole.value }
|
||||
})
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/users/batch', { users: usersToCreate, default_role: batchRole.value })
|
||||
const msg = `成功 ${data.success},跳过 ${data.skipped},失败 ${data.failed}`
|
||||
if (data.failed > 0 && data.errors?.length) {
|
||||
const errDetail = data.errors.map(e => `${e.username || '(空)'}: ${e.error}`).join('\n')
|
||||
ElMessageBox.alert(`${msg}\n\n失败详情:\n${errDetail}`, '导入结果', { type: 'warning' })
|
||||
} else {
|
||||
ElMessage.success(msg)
|
||||
}
|
||||
batchVisible.value = false
|
||||
batchText.value = ''
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '导入失败')
|
||||
} finally { batchLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- LDAP 导入 ----
|
||||
async function loadLDAPUsers() {
|
||||
ldapLoading.value = true
|
||||
ldapUsers.value = []
|
||||
try {
|
||||
const { data } = await api.post('/ldap/users')
|
||||
ldapUsers.value = data || []
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'LDAP 加载失败')
|
||||
} finally { ldapLoading.value = false }
|
||||
}
|
||||
async function doLDAPImport() {
|
||||
if (!selectedDNs.value.length) return
|
||||
importLoading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/users/import-ldap', { dns: selectedDNs.value })
|
||||
ElMessage.success(`成功 ${data.success},跳过 ${data.skipped},失败 ${data.failed}`)
|
||||
ldapImportVisible.value = false
|
||||
selectedDNs.value = []
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '导入失败')
|
||||
} finally { importLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- LDAP 配置 ----
|
||||
async function saveLDAP() {
|
||||
try {
|
||||
await api.put('/ldap', ldap.value)
|
||||
ldap.value.bind_password = ''
|
||||
ElMessage.success('LDAP 配置已保存')
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || '保存失败') }
|
||||
}
|
||||
async function testLDAP() {
|
||||
try {
|
||||
await api.post('/ldap/test')
|
||||
ElMessage.success('LDAP 连接成功')
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || '连接失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
+406
-18
@@ -1,29 +1,417 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><div><b>VPN 拨号用户</b><span style="margin-left:8px;color:#909399;font-size:12px">本地密码或 LDAP 身份,按 OpenVPN 用户统计流量</span></div><el-button type="primary" @click="openCreate">添加用户</el-button></div></template>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>VPN 拨号用户</b>
|
||||
<span style="margin-left:8px;color:#909399;font-size:12px">
|
||||
OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)
|
||||
</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">添加用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="users" size="small">
|
||||
<el-table-column prop="username" label="用户名" /><el-table-column prop="display_name" label="姓名" /><el-table-column label="隧道"><template #default="{row}">{{ tunnelName(row.tunnel_id) }}</template></el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="90" /><el-table-column label="流量 ↓ / ↑" width="170"><template #default="{row}">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template></el-table-column>
|
||||
<el-table-column label="最近在线" width="180"><template #default="{row}">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{row}"><el-tag :type="row.enabled ? 'success':'info'" size="small">{{ row.enabled ? '启用':'禁用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="180"><template #default="{row}"><el-button link size="small" @click="download(row)">下载配置</el-button><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用':'启用' }}</el-button><el-button type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="display_name" label="姓名" />
|
||||
<el-table-column label="隧道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ tunnelName(row.tunnel_id) }}
|
||||
<el-tag size="small" style="margin-left:6px" type="info">{{ tunnelProto(row.tunnel_id) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="90" />
|
||||
<el-table-column label="OTP / MFA" width="130">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isOpenVPN(row.tunnel_id)">
|
||||
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
|
||||
{{ row.mfa_enabled ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.mfa_has_pin" size="small" type="warning" style="margin-left:4px">PIN</el-tag>
|
||||
<el-tag v-if="isLocked(row)" size="small" type="danger" style="margin-left:4px">锁定</el-tag>
|
||||
</template>
|
||||
<span v-else style="color:#c0c4cc">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流量 ↓ / ↑" width="170">
|
||||
<template #default="{ row }">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近在线" width="180">
|
||||
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button link size="small" @click="download(row)">下载配置</el-button>
|
||||
<el-button v-if="isOpenVPN(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">OTP 管理</el-button>
|
||||
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
|
||||
<el-button type="danger" link size="small" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px"><el-form label-width="100px"><el-form-item label="OpenVPN 隧道" required><el-select v-model="form.tunnel_id" style="width:100%"><el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" /></el-select></el-form-item><el-form-item label="用户名" required><el-input v-model="form.username" /></el-form-item><el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item><el-form-item label="认证来源"><el-radio-group v-model="form.source"><el-radio-button value="local">本地</el-radio-button><el-radio-button value="ldap">LDAP</el-radio-button></el-radio-group></el-form-item><el-form-item v-if="form.source==='local'" label="密码" required><el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" /></el-form-item><el-form-item v-else label="LDAP DN" required><el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></el-form><template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template></el-dialog>
|
||||
|
||||
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="拨号隧道" required>
|
||||
<el-select v-model="form.tunnel_id" style="width:100%" filterable>
|
||||
<el-option v-for="t in tunnels" :key="t.id" :value="t.id"
|
||||
:label="`${t.name} (${protocolDisplay(t.protocol)})`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username">
|
||||
<template #append>
|
||||
<el-button @click="openPlatformPicker">从已有用户选</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item>
|
||||
<el-form-item label="认证来源">
|
||||
<el-radio-group v-model="form.source">
|
||||
<el-radio-button value="local">本地</el-radio-button>
|
||||
<el-radio-button value="ldap">LDAP</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.source === 'local'" label="密码" required>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="LDAP DN" required>
|
||||
<el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="create">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从平台用户选择对话框 -->
|
||||
<el-dialog v-model="pickerVisible" title="选择已有平台用户" width="560px" align-center>
|
||||
<el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading"
|
||||
@row-click="pickPlatformUser">
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="160" />
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.source === 'ldap' ? 'primary' : 'info'" size="small">{{ row.source === 'ldap' ? 'LDAP' : '本地' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无平台用户" /></template>
|
||||
</el-table>
|
||||
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">点击行即选中并自动填充</p>
|
||||
</el-dialog>
|
||||
|
||||
<!-- OTP 双因素认证管理 -->
|
||||
<el-dialog v-model="otpVisible" :title="`双因素认证 — ${otpUser?.username || ''}`" width="520px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
title="基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。" />
|
||||
|
||||
<el-descriptions :column="1" border size="small" v-if="otpStatus">
|
||||
<el-descriptions-item label="绑定状态">
|
||||
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" size="small">
|
||||
{{ otpStatus.mfa_enabled ? '已绑定 TOTP' : '未绑定' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="静态 PIN">{{ otpStatus.mfa_has_pin ? '已设置' : '未设置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败次数">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="锁定">
|
||||
<span v-if="otpStatus.mfa_locked" style="color:#f56c6c">
|
||||
是(至 {{ otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString('zh-CN') : '' }})
|
||||
</span>
|
||||
<span v-else>否</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近 OTP 成功">
|
||||
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString('zh-CN') : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="兼容">{{ otpStatus.compatible }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px">
|
||||
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">绑定 Authenticator</el-button>
|
||||
<el-button v-else type="danger" plain @click="disableMFA">关闭 MFA</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">重新生成备用码</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">设置/清除 PIN</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">解锁</el-button>
|
||||
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">导出密钥</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="bindStep" style="margin-top:16px;text-align:center;border-top:1px solid #ebeef5;padding-top:16px">
|
||||
<p style="font-size:13px;color:#606266">使用 Google Authenticator / Microsoft Authenticator / FreeOTP 扫描</p>
|
||||
<canvas ref="mfaCanvas"></canvas>
|
||||
<div v-if="mfaSecret" style="margin-top:8px;font-size:12px;color:#909399;word-break:break-all">
|
||||
base32 密钥:{{ mfaSecret }}
|
||||
</div>
|
||||
<el-input v-model="mfaCode" placeholder="输入 6 位验证码确认绑定" style="margin-top:12px;max-width:280px" maxlength="8" />
|
||||
<div style="margin-top:10px">
|
||||
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">验证并启用</el-button>
|
||||
<el-button @click="bindStep = false">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="otpStatus?.mfa_enabled" type="success" :closable="false" style="margin-top:16px"
|
||||
title="客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN,则填 PIN+验证码,如 1234123456)。也支持密码后直接附加 6 位码。" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="pinVisible" title="设置静态 PIN" width="400px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px"
|
||||
title="连接时第二因子需输入 PIN + 6 位动态验证码(无空格拼接)。留空保存可清除已设置的 PIN。" />
|
||||
<el-input v-model="pinValue" type="password" show-password placeholder="4-32 位 PIN,留空清除" />
|
||||
<template #footer>
|
||||
<el-button @click="pinVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePin">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
const users=ref([]), tunnels=ref([]), visible=ref(false)
|
||||
const form=ref({tunnel_id:null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true})
|
||||
const bytes=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(n>=1024&&i<4){n/=1024;i++}return `${n.toFixed(1)} ${u[i]}`}
|
||||
const tunnelName=id=>tunnels.value.find(t=>t.id===id)?.name||`#${id}`
|
||||
async function load(){const [u,t]=await Promise.all([api.get('/vpn-users'),api.get('/tunnels',{params:{protocol:'openvpn'}})]);users.value=u.data||[];tunnels.value=t.data||[]}
|
||||
function openCreate(){form.value={tunnel_id:tunnels.value[0]?.id||null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true};visible.value=true}
|
||||
async function create(){try{await api.post('/vpn-users',form.value);visible.value=false;ElMessage.success('拨号用户已创建');load()}catch(e){ElMessage.error(e.response?.data?.error||'创建失败')}}
|
||||
async function toggle(row){await api.put(`/vpn-users/${row.id}`,{enabled:!row.enabled,display_name:row.display_name,email:row.email});load()}
|
||||
async function remove(row){await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`,'确认');await api.delete(`/vpn-users/${row.id}`);load()}
|
||||
async function download(row){try{const res=await fetch(`/api/vpn-users/${row.id}/config`,{headers:{Authorization:`Bearer ${localStorage.getItem('vpn_token')}`}});if(!res.ok){const data=await res.json();throw new Error(data.error)}const url=URL.createObjectURL(await res.blob());const a=document.createElement('a');a.href=url;a.download=`openvpn-${row.username}.ovpn`;a.click();URL.revokeObjectURL(url)}catch(e){ElMessage.error(e.message||'下载失败')}}
|
||||
import { protocolDisplay } from '../protocols'
|
||||
|
||||
const users = ref([])
|
||||
const tunnels = ref([])
|
||||
const visible = ref(false)
|
||||
const form = ref({ tunnel_id: null, username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true })
|
||||
|
||||
// 平台用户选择器
|
||||
const pickerVisible = ref(false)
|
||||
const pickerLoading = ref(false)
|
||||
const platformUsers = ref([])
|
||||
|
||||
const otpVisible = ref(false)
|
||||
const otpUser = ref(null)
|
||||
const otpStatus = ref(null)
|
||||
const bindStep = ref(false)
|
||||
const mfaCode = ref('')
|
||||
const mfaSecret = ref('')
|
||||
const mfaCanvas = ref(null)
|
||||
const mfaSaving = ref(false)
|
||||
const pinVisible = ref(false)
|
||||
const pinValue = ref('')
|
||||
|
||||
const bytes = n => {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return `${n.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
const tunnelName = id => tunnels.value.find(t => t.id === id)?.name || `#${id}`
|
||||
const tunnelProto = id => {
|
||||
const t = tunnels.value.find(x => x.id === id)
|
||||
return t ? protocolDisplay(t.protocol) : ''
|
||||
}
|
||||
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
|
||||
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
|
||||
|
||||
async function load() {
|
||||
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
|
||||
users.value = u.data || []
|
||||
tunnels.value = (all.data || []).filter(t => ['openvpn', 'l2tp', 'ikev2'].includes(t.protocol))
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = {
|
||||
tunnel_id: tunnels.value[0]?.id || null,
|
||||
username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 从平台用户选择导入
|
||||
async function openPlatformPicker() {
|
||||
pickerVisible.value = true
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/users')
|
||||
platformUsers.value = data || []
|
||||
} catch { platformUsers.value = [] } finally { pickerLoading.value = false }
|
||||
}
|
||||
function pickPlatformUser(row) {
|
||||
form.value.username = row.username
|
||||
form.value.display_name = row.email ? row.email.split('@')[0] : row.username
|
||||
form.value.email = row.email || ''
|
||||
form.value.source = row.source === 'ldap' ? 'ldap' : 'local'
|
||||
// LDAP 用户需填 DN(留空由后端校验提示)
|
||||
form.value.ldap_dn = row.source === 'ldap' ? `uid=${row.username}` : ''
|
||||
pickerVisible.value = false
|
||||
ElMessage.success(`已填充用户「${row.username}」信息`)
|
||||
}
|
||||
|
||||
async function create() {
|
||||
try {
|
||||
await api.post('/vpn-users', form.value)
|
||||
visible.value = false
|
||||
ElMessage.success('拨号用户已创建')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(row) {
|
||||
await api.put(`/vpn-users/${row.id}`, { enabled: !row.enabled, display_name: row.display_name, email: row.email })
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`, '确认')
|
||||
await api.delete(`/vpn-users/${row.id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function download(row) {
|
||||
try {
|
||||
const res = await fetch(`/api/vpn-users/${row.id}/config`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error)
|
||||
}
|
||||
const t = tunnels.value.find(x => x.id === row.tunnel_id)
|
||||
const ext = t?.protocol === 'openvpn' ? 'ovpn' : 'txt'
|
||||
const url = URL.createObjectURL(await res.blob())
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${t?.protocol || 'vpn'}-${row.username}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openOTPManager(row) {
|
||||
otpUser.value = row
|
||||
bindStep.value = false
|
||||
otpVisible.value = true
|
||||
await refreshOTPStatus()
|
||||
}
|
||||
|
||||
async function refreshOTPStatus() {
|
||||
if (!otpUser.value) return
|
||||
try {
|
||||
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa`)
|
||||
otpStatus.value = data
|
||||
} catch (e) {
|
||||
otpStatus.value = {
|
||||
mfa_enabled: otpUser.value.mfa_enabled,
|
||||
mfa_has_pin: otpUser.value.mfa_has_pin,
|
||||
mfa_fail_count: otpUser.value.mfa_fail_count || 0,
|
||||
mfa_locked: isLocked(otpUser.value),
|
||||
mfa_locked_until: otpUser.value.mfa_locked_until,
|
||||
mfa_last_used_at: otpUser.value.mfa_last_used_at,
|
||||
compatible: 'TOTP 标准(兼容 Google/Microsoft Authenticator)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupMFA() {
|
||||
try {
|
||||
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
|
||||
mfaCode.value = ''
|
||||
mfaSecret.value = data.secret || ''
|
||||
bindStep.value = true
|
||||
await nextTick()
|
||||
if (mfaCanvas.value && data.otpauth_url) {
|
||||
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '无法开始 MFA 设置')
|
||||
}
|
||||
}
|
||||
|
||||
async function enableMFA() {
|
||||
if (!mfaCode.value) return ElMessage.warning('请输入验证码')
|
||||
mfaSaving.value = true
|
||||
try {
|
||||
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
|
||||
bindStep.value = false
|
||||
const codes = (data.backup_codes || []).join(' ')
|
||||
ElMessageBox.alert(
|
||||
`双因素认证已启用。\n\n一次性备用码(请妥善保存,丢失手机时用于登录):\n${codes}\n\n客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN 则填 PIN+验证码)。`,
|
||||
'绑定成功',
|
||||
{ type: 'success' }
|
||||
)
|
||||
await refreshOTPStatus()
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '验证失败')
|
||||
} finally {
|
||||
mfaSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableMFA() {
|
||||
await ElMessageBox.confirm(`关闭 ${otpUser.value.username} 的 MFA?`, '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
|
||||
ElMessage.success('MFA 已关闭')
|
||||
await refreshOTPStatus()
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function regenBackup() {
|
||||
try {
|
||||
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
|
||||
ElMessageBox.alert(`新备用码(旧码失效):\n\n${(data.backup_codes || []).join(' ')}`, '备用码', { type: 'success' })
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
function showPinDialog() {
|
||||
pinValue.value = ''
|
||||
pinVisible.value = true
|
||||
}
|
||||
|
||||
async function savePin() {
|
||||
try {
|
||||
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
|
||||
pinVisible.value = false
|
||||
ElMessage.success(pinValue.value ? 'PIN 已设置' : 'PIN 已清除')
|
||||
await refreshOTPStatus()
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockMFA() {
|
||||
try {
|
||||
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
|
||||
ElMessage.success('已解锁')
|
||||
await refreshOTPStatus()
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '解锁失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function exportOtpSecrets() {
|
||||
try {
|
||||
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
|
||||
ElMessageBox.alert(
|
||||
`TOTP 密钥行(敏感信息,请勿泄露):\n\n${data.line}\n\n格式说明:${data.format}`,
|
||||
'导出密钥',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in new issue
Block a user