From 1e2f8ef24fb679816c6cb7181396ff291f51431f Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Thu, 23 Jul 2026 01:42:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?=E2=80=94=20UI/UX=E9=87=8D=E6=9E=84=20+=20=E6=B5=81=E9=87=8F?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=B8=8E=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20+=20=E5=8D=8F=E8=AE=AE=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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参数) --- README.md | 183 ++-- backend/internal/api/api.go | 797 ++++++++++++++++-- backend/internal/api/identity.go | 533 +++++++++++- backend/internal/binmgr/binmgr.go | 480 +++++++++++ backend/internal/model/model.go | 114 ++- backend/internal/monitor/monitor.go | 64 +- backend/internal/service/common.go | 22 +- backend/internal/service/frp/frp.go | 55 +- backend/internal/service/ikev2/ikev2.go | 427 ++++++++++ backend/internal/service/ikev2/ikev2_test.go | 18 + backend/internal/service/ipsec/ipsec.go | 90 +- backend/internal/service/l2tp/l2tp.go | 64 +- backend/internal/service/openvpn/openvpn.go | 785 ++++++++++++++++- .../internal/service/openvpn/openvpn_test.go | 110 +++ .../internal/service/wireguard/wireguard.go | 155 +++- .../service/wireguard/wireguard_test.go | 8 +- backend/internal/sessionmgr/sessionmgr.go | 165 ++++ backend/internal/sshutil/sshutil.go | 129 +++ backend/internal/store/store.go | 18 +- backend/internal/trafficmgr/trafficmgr.go | 205 +++++ backend/main.go | 2 + frontend/src/api.js | 28 +- frontend/src/components/ProtocolIcon.vue | 73 ++ frontend/src/main.js | 1 + frontend/src/protocols.js | 138 ++- frontend/src/router.js | 7 +- frontend/src/style.css | 13 +- frontend/src/styles/tokens.css | 78 ++ frontend/src/views/Dashboard.vue | 175 +++- frontend/src/views/IpsecPeer.vue | 2 +- frontend/src/views/Layout.vue | 137 ++- frontend/src/views/Login.vue | 195 ++++- frontend/src/views/MfaVerify.vue | 178 ++++ frontend/src/views/Nodes.vue | 206 ++++- frontend/src/views/NotFound.vue | 18 + frontend/src/views/Plugins.vue | 26 +- frontend/src/views/Sessions.vue | 139 +++ frontend/src/views/Settings.vue | 131 ++- frontend/src/views/TunnelEditor.vue | 5 +- frontend/src/views/TunnelWizard.vue | 184 +++- frontend/src/views/Tunnels.vue | 357 +++++++- frontend/src/views/Users.vue | 304 ++++++- frontend/src/views/VPNUsers.vue | 424 +++++++++- 43 files changed, 6783 insertions(+), 460 deletions(-) create mode 100644 backend/internal/binmgr/binmgr.go create mode 100644 backend/internal/service/ikev2/ikev2.go create mode 100644 backend/internal/service/ikev2/ikev2_test.go create mode 100644 backend/internal/sessionmgr/sessionmgr.go create mode 100644 backend/internal/sshutil/sshutil.go create mode 100644 backend/internal/trafficmgr/trafficmgr.go create mode 100644 frontend/src/components/ProtocolIcon.vue create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/views/MfaVerify.vue create mode 100644 frontend/src/views/NotFound.vue create mode 100644 frontend/src/views/Sessions.vue diff --git a/README.md b/README.md index e6a168b..aea8daa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index a28c2b6..af338b0 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -2,6 +2,7 @@ package api import ( + "bufio" "crypto/rand" "encoding/base64" "encoding/json" @@ -9,6 +10,7 @@ import ( "net" "net/http" "os" + "path/filepath" "strconv" "strings" "sync" @@ -17,6 +19,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" + "vpn-platform/backend/internal/binmgr" "vpn-platform/backend/internal/core" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service" @@ -24,7 +27,10 @@ import ( "vpn-platform/backend/internal/service/ipsec" "vpn-platform/backend/internal/service/openvpn" "vpn-platform/backend/internal/service/wireguard" + "vpn-platform/backend/internal/sessionmgr" + "vpn-platform/backend/internal/sshutil" "vpn-platform/backend/internal/store" + "vpn-platform/backend/internal/trafficmgr" ) // Server API 服务器 @@ -34,17 +40,41 @@ type Server struct { func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr} } -// ---- 简易 Token 会话 ---- +// ---- Token 会话(内存态,定期清理过期项)---- var ( sessions = map[string]sessionInfo{} sessionsMu sync.RWMutex ) type sessionInfo struct { - username string - userID uint - role string - expireAt time.Time + username string + userID uint + role string + mustChange bool + expireAt time.Time +} + +func init() { + go func() { + ticker := time.NewTicker(10 * time.Minute) + for range ticker.C { + now := time.Now() + sessionsMu.Lock() + for token, sess := range sessions { + if now.After(sess.expireAt) { + delete(sessions, token) + } + } + sessionsMu.Unlock() + loginMu.Lock() + for key, rec := range loginFails { + if now.After(rec.lockedUntil) && now.Sub(rec.lastFail) > 30*time.Minute { + delete(loginFails, key) + } + } + loginMu.Unlock() + } + }() } func issueToken(user *model.User) string { @@ -52,15 +82,100 @@ func issueToken(user *model.User) string { _, _ = rand.Read(raw) token := base64.RawURLEncoding.EncodeToString(raw) sessionsMu.Lock() - sessions[token] = sessionInfo{username: user.Username, userID: user.ID, role: user.Role, expireAt: time.Now().Add(24 * time.Hour)} + sessions[token] = sessionInfo{username: user.Username, userID: user.ID, role: user.Role, mustChange: user.MustChangePassword, expireAt: time.Now().Add(24 * time.Hour)} sessionsMu.Unlock() return token } +func revokeToken(token string) { + sessionsMu.Lock() + delete(sessions, token) + sessionsMu.Unlock() +} + +func clearMustChange(userID uint) { + sessionsMu.Lock() + for token, sess := range sessions { + if sess.userID == userID { + sess.mustChange = false + sessions[token] = sess + } + } + sessionsMu.Unlock() +} + +// ---- 登录防爆破:同 IP+账号 连续失败 5 次锁定 10 分钟 ---- +var ( + loginFails = map[string]*loginFailRecord{} + loginMu sync.Mutex +) + +type loginFailRecord struct { + fails int + lastFail time.Time + lockedUntil time.Time +} + +func loginLockRemaining(key string) time.Duration { + loginMu.Lock() + defer loginMu.Unlock() + if rec, ok := loginFails[key]; ok && time.Now().Before(rec.lockedUntil) { + return time.Until(rec.lockedUntil) + } + return 0 +} + +func recordLoginFail(key string) { + loginMu.Lock() + defer loginMu.Unlock() + rec, ok := loginFails[key] + if !ok { + rec = &loginFailRecord{} + loginFails[key] = rec + } + rec.fails++ + rec.lastFail = time.Now() + if rec.fails >= 5 { + rec.lockedUntil = time.Now().Add(10 * time.Minute) + rec.fails = 0 + } +} + +func resetLoginFail(key string) { + loginMu.Lock() + delete(loginFails, key) + loginMu.Unlock() +} + +// ---- 专线操作互斥锁 ---- +var tunnelLocks sync.Map + +func lockTunnel(id uint) *sync.Mutex { + v, _ := tunnelLocks.LoadOrStore(id, &sync.Mutex{}) + mu := v.(*sync.Mutex) + mu.Lock() + return mu +} + +func securityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + h := c.Writer.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'") + c.Next() + } +} + func authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("Authorization") token = strings.TrimPrefix(token, "Bearer ") + // SSE (EventSource) 不支持自定义 header,降级从 query 参数读 token + if token == "" { + token = c.Query("token") + } sessionsMu.RLock() sess, ok := sessions[token] sessionsMu.RUnlock() @@ -71,68 +186,114 @@ func authMiddleware() gin.HandlerFunc { c.Set("username", sess.username) c.Set("user_id", sess.userID) c.Set("role", sess.role) + c.Set("must_change", sess.mustChange) + c.Set("token", token) c.Next() } } -// RegisterRoutes 注册全部路由 +func requirePasswordChanged() gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetBool("must_change") { + path := c.Request.URL.Path + if path != "/api/password" && path != "/api/logout" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "首次登录必须修改密码", "must_change_password": true}) + return + } + } + c.Next() + } +} + +// RegisterRoutes 权限:viewer 只读;operator 专线运维;admin 用户/LDAP/插件/审计 func (s *Server) RegisterRoutes(r *gin.Engine) { + r.Use(securityHeaders()) api := r.Group("/api") api.POST("/login", s.login) api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) auth := api.Group("") - auth.Use(authMiddleware()) + auth.Use(authMiddleware(), requirePasswordChanged()) { - auth.GET("/overview", s.overview) - auth.GET("/protocols", s.listProtocols) + auth.POST("/logout", s.logout) auth.POST("/password", s.changePassword) auth.GET("/mfa/status", s.mfaStatus) auth.POST("/mfa/setup", s.mfaSetup) auth.POST("/mfa/enable", s.mfaEnable) auth.POST("/mfa/disable", s.mfaDisable) + // 只读 + auth.GET("/overview", s.overview) + auth.GET("/protocols", s.listProtocols) + auth.GET("/tunnels", s.listTunnels) + auth.GET("/tunnels/:id", s.getTunnel) + auth.GET("/tunnels/:id/status", s.tunnelStatus) + auth.GET("/tunnels/:id/policy", s.getTrafficPolicy) + auth.GET("/ipsec/templates", s.ipsecTemplates) + auth.GET("/nodes", s.listNodes) + auth.GET("/plugins", s.listPlugins) + auth.GET("/sessions", s.listSessions) + auth.GET("/sessions/log", s.listConnectionLogs) + auth.GET("/traffic/alerts", s.listTrafficAlerts) + + // 专线运维 operator+ + ops := auth.Group("") + ops.Use(requireRole("admin", "operator")) + ops.POST("/tunnels", s.createTunnel) + ops.PUT("/tunnels/:id", s.updateTunnel) + ops.DELETE("/tunnels/:id", s.deleteTunnel) + ops.POST("/tunnels/:id/start", s.startTunnel) + ops.POST("/tunnels/:id/stop", s.stopTunnel) + ops.POST("/tunnels/:id/test", s.testConnectivity) + ops.GET("/tunnels/:id/logs", s.tunnelLogs) + ops.POST("/ipsec/compare", s.ipsecCompare) + ops.GET("/tunnels/:id/config", s.downloadConfig) + ops.GET("/vpn-users/:id/config", s.downloadVPNUserConfig) + ops.GET("/tunnels/:id/wireguard/peers", s.listWireGuardPeers) + ops.POST("/tunnels/:id/wireguard/peers", s.addWireGuardPeer) + ops.DELETE("/tunnels/:id/wireguard/peers/:peer_id", s.removeWireGuardPeer) + ops.GET("/tunnels/:id/wireguard/peers/:peer_id/config", s.downloadWireGuardPeerConfig) + ops.POST("/tunnels/:id/openvpn/pki", s.initializeOpenVPNPKI) + ops.GET("/binaries/status", s.listBinaryStatus) + ops.POST("/binaries/install/:name", s.installBinary) + ops.GET("/binaries/install-hint/:name", s.binaryInstallHint) + ops.PUT("/tunnels/:id/policy", s.saveTrafficPolicy) + + // 平台管理 admin admin := auth.Group("") admin.Use(requireRole("admin")) admin.GET("/users", s.listUsers) admin.POST("/users", s.createUser) admin.PUT("/users/:id", s.updateUser) admin.DELETE("/users/:id", s.deleteUser) + admin.POST("/users/batch", s.batchCreateUsers) + admin.POST("/users/import-ldap", s.importLDAPUsers) admin.GET("/ldap", s.getLDAPConfig) admin.PUT("/ldap", s.saveLDAPConfig) admin.POST("/ldap/test", s.testLDAP) + admin.POST("/ldap/users", s.listLDAPUsers) + admin.POST("/sessions/:id/disconnect", s.disconnectSession) admin.GET("/vpn-users", s.listVPNUsers) admin.POST("/vpn-users", s.createVPNUser) admin.PUT("/vpn-users/:id", s.updateVPNUser) admin.DELETE("/vpn-users/:id", s.deleteVPNUser) - admin.POST("/tunnels/:id/openvpn/pki", s.initializeOpenVPNPKI) - admin.GET("/vpn-users/:id/config", s.downloadVPNUserConfig) - admin.GET("/tunnels/:id/wireguard/peers/:peer_id/config", s.downloadWireGuardPeerConfig) - - auth.GET("/tunnels", s.listTunnels) - auth.POST("/tunnels", s.createTunnel) - auth.GET("/tunnels/:id", s.getTunnel) - auth.PUT("/tunnels/:id", s.updateTunnel) - auth.DELETE("/tunnels/:id", s.deleteTunnel) - auth.POST("/tunnels/:id/start", s.startTunnel) - auth.POST("/tunnels/:id/stop", s.stopTunnel) - auth.GET("/tunnels/:id/config", s.downloadConfig) - auth.GET("/tunnels/:id/status", s.tunnelStatus) - auth.POST("/tunnels/:id/test", s.testConnectivity) - - auth.GET("/ipsec/templates", s.ipsecTemplates) - auth.POST("/ipsec/compare", s.ipsecCompare) - - auth.GET("/nodes", s.listNodes) - auth.POST("/nodes", s.createNode) - auth.DELETE("/nodes/:id", s.deleteNode) - - auth.GET("/plugins", s.listPlugins) - auth.POST("/plugins", s.createPlugin) - auth.PUT("/plugins/:id", s.updatePlugin) - auth.DELETE("/plugins/:id", s.deletePlugin) - - auth.GET("/audit", s.listAudit) + admin.POST("/vpn-users/:id/mfa/setup", s.vpnUserMFASetup) + admin.POST("/vpn-users/:id/mfa/enable", s.vpnUserMFAEnable) + admin.POST("/vpn-users/:id/mfa/disable", s.vpnUserMFADisable) + admin.GET("/vpn-users/:id/mfa", s.vpnUserMFAStatus) + admin.POST("/vpn-users/:id/mfa/pin", s.vpnUserMFAPin) + admin.POST("/vpn-users/:id/mfa/backup-codes", s.vpnUserMFABackupCodes) + admin.POST("/vpn-users/:id/mfa/unlock", s.vpnUserMFAUnlock) + admin.GET("/vpn-users/:id/mfa/otp-secrets", s.vpnUserOTPSecretsLine) + admin.POST("/nodes", s.createNode) + admin.POST("/nodes/test", s.testNodeConnection) + admin.PUT("/nodes/:id", s.updateNode) + admin.DELETE("/nodes/:id", s.deleteNode) + admin.POST("/nodes/:id/verify", s.verifyNode) + admin.POST("/plugins", s.createPlugin) + admin.PUT("/plugins/:id", s.updatePlugin) + admin.DELETE("/plugins/:id", s.deletePlugin) + admin.GET("/audit", s.listAudit) } } @@ -166,20 +327,125 @@ func (s *Server) downloadVPNUserConfig(c *gin.Context) { } t, err := store.GetTunnel(user.TunnelID) if err != nil { - c.JSON(404, gin.H{"error": "OpenVPN 隧道不存在"}) + c.JSON(404, gin.H{"error": "隧道不存在"}) return } - if t.Protocol != "openvpn" { - c.JSON(400, gin.H{"error": "拨号用户不属于 OpenVPN 隧道"}) + switch t.Protocol { + case "openvpn": + conf, err := openvpn.New().GenerateUserConfig(t, &user) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=openvpn-%s.ovpn", user.Username)) + c.Data(200, "application/x-openvpn-profile", []byte(conf)) + case "l2tp", "ikev2": + // 导出通用接入说明(含隧道级参数);用户密码不回显,需用户自知。 + p, err := s.mgr.Get(t.Protocol) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + base, err := p.GenerateClientConfig(t) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + conf := base + fmt.Sprintf("\n----- 本账号 -----\n用户名: %s\n(密码为创建时填写的拨号密码,平台不回显)\n", user.Username) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s-%s.txt", t.Protocol, user.Username)) + c.Data(200, "text/plain; charset=utf-8", []byte(conf)) + default: + c.JSON(400, gin.H{"error": "该协议隧道不支持拨号用户配置下载"}) + } +} + +func (s *Server) listWireGuardPeers(c *gin.Context) { + t, err := s.mustTunnel(c) + if err != nil { return } - conf, err := openvpn.New().GenerateUserConfig(t, &user) + if t.Protocol != "wireguard" { + c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 管理"}) + return + } + cfg, err := store.TunnelConfig(t) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=openvpn-%s.ovpn", user.Username)) - c.Data(200, "application/x-openvpn-profile", []byte(conf)) + resp := gin.H{"peers": cfg["peers"]} + if st, ok := s.mgr.GetStatus(t.ID); ok { + resp["runtime_peers"] = st.Peers + } + c.JSON(200, resp) +} + +func (s *Server) addWireGuardPeer(c *gin.Context) { + t, err := s.mustTunnel(c) + if err != nil { + return + } + if t.Protocol != "wireguard" { + c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 管理"}) + return + } + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + wasRunning := t.State == "running" + wg := wireguard.New() + if wasRunning { + _ = wg.Stop(t) + } + p, err := wg.AddPeer(t, req) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if wasRunning { + if err := wg.Start(t); err != nil { + t.State = "error" + t.LastError = err.Error() + store.DB.Model(t).Updates(map[string]interface{}{"state": t.State, "last_error": t.LastError}) + c.JSON(500, gin.H{"error": "Peer 已保存但重启失败: " + err.Error(), "peer": p}) + return + } + store.DB.Model(t).Updates(map[string]interface{}{"state": "running", "last_error": ""}) + } + store.Audit(c.GetString("username"), "add_wireguard_peer", t.Name, p.ID) + c.JSON(200, p) +} + +func (s *Server) removeWireGuardPeer(c *gin.Context) { + t, err := s.mustTunnel(c) + if err != nil { + return + } + if t.Protocol != "wireguard" { + c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 管理"}) + return + } + wasRunning := t.State == "running" + wg := wireguard.New() + if wasRunning { + _ = wg.Stop(t) + } + if err := wg.RemovePeer(t, c.Param("peer_id")); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if wasRunning { + if err := wg.Start(t); err != nil { + store.DB.Model(t).Updates(map[string]interface{}{"state": "error", "last_error": err.Error()}) + c.JSON(500, gin.H{"error": "Peer 已删除但重启失败: " + err.Error()}) + return + } + store.DB.Model(t).Updates(map[string]interface{}{"state": "running", "last_error": ""}) + } + store.Audit(c.GetString("username"), "remove_wireguard_peer", t.Name, c.Param("peer_id")) + c.JSON(200, gin.H{"ok": true}) } func (s *Server) downloadWireGuardPeerConfig(c *gin.Context) { @@ -212,20 +478,42 @@ func (s *Server) login(c *gin.Context) { c.JSON(400, gin.H{"error": "参数错误"}) return } + failKey := c.ClientIP() + "|" + req.Username + if remaining := loginLockRemaining(failKey); remaining > 0 { + store.Audit(req.Username, "login_locked", c.ClientIP(), "账号锁定中") + c.JSON(429, gin.H{"error": fmt.Sprintf("失败次数过多,账号已锁定,请 %.0f 分钟后再试", remaining.Minutes())}) + return + } user, err := s.authenticateUser(req.Username, req.Password) if err != nil { + recordLoginFail(failKey) + store.Audit(req.Username, "login_failed", c.ClientIP(), "用户名或密码错误") c.JSON(401, gin.H{"error": "用户名或密码错误"}) return } if !user.Enabled { + store.Audit(req.Username, "login_failed", c.ClientIP(), "用户已禁用") c.JSON(403, gin.H{"error": "用户已禁用"}) return } if err := verifyMFA(user, req.MFACode); err != nil { + recordLoginFail(failKey) + store.Audit(req.Username, "login_failed", c.ClientIP(), "MFA 校验失败") c.JSON(401, gin.H{"error": err.Error(), "mfa_required": true}) return } - c.JSON(200, gin.H{"token": issueToken(user), "username": user.Username, "role": user.Role}) + resetLoginFail(failKey) + store.Audit(user.Username, "login_success", c.ClientIP(), "") + c.JSON(200, gin.H{ + "token": issueToken(user), "username": user.Username, "role": user.Role, + "must_change_password": user.MustChangePassword, + }) +} + +func (s *Server) logout(c *gin.Context) { + revokeToken(c.GetString("token")) + store.Audit(c.GetString("username"), "logout", c.ClientIP(), "") + c.JSON(200, gin.H{"ok": true}) } func (s *Server) changePassword(c *gin.Context) { @@ -233,8 +521,8 @@ func (s *Server) changePassword(c *gin.Context) { Old string `json:"old_password"` New string `json:"new_password"` } - if err := c.ShouldBindJSON(&req); err != nil || len(req.New) < 6 { - c.JSON(400, gin.H{"error": "新密码至少 6 位"}) + if err := c.ShouldBindJSON(&req); err != nil || len(req.New) < 8 { + c.JSON(400, gin.H{"error": "新密码至少 8 位"}) return } username := c.GetString("username") @@ -247,8 +535,14 @@ func (s *Server) changePassword(c *gin.Context) { c.JSON(400, gin.H{"error": "原密码错误"}) return } + // 新旧密码不能相同(与 admin 重置规则一致:最短 8 位) + if req.Old == req.New { + c.JSON(400, gin.H{"error": "新密码不能与原密码相同"}) + return + } hash, _ := bcrypt.GenerateFromPassword([]byte(req.New), bcrypt.DefaultCost) - store.DB.Model(&user).Update("password", string(hash)) + store.DB.Model(&user).Updates(map[string]interface{}{"password": string(hash), "must_change_password": false}) + clearMustChange(user.ID) store.Audit(username, "change_password", username, "修改登录密码") c.JSON(200, gin.H{"ok": true}) } @@ -285,6 +579,7 @@ func (s *Server) overview(c *gin.Context) { c.JSON(200, gin.H{ "total": total, "running": running, "nodes": nodeCount, "plugins": pluginCount, + "online_sessions": sessionmgr.CountActive(), "rx_bytes": sum.Rx, "tx_bytes": sum.Tx, "protocol_distribution": dist, "expiring": expiring, }) @@ -306,6 +601,94 @@ func (s *Server) listProtocols(c *gin.Context) { c.JSON(200, out) } +// ---- 协议二进制管理 ---- + +func (s *Server) listBinaryStatus(c *gin.Context) { + c.JSON(200, binmgr.CheckAll()) +} + +// installBinary 下载部署协议二进制(FRP / OpenVPN 支持从 GitHub Release 下载) +func (s *Server) installBinary(c *gin.Context) { + name := c.Param("name") + if name != "frp" && name != "openvpn" { + c.JSON(400, gin.H{"error": "仅 FRP / OpenVPN 支持平台自动下载部署,其他协议请使用系统包管理器安装"}) + return + } + version, err := binmgr.DownloadBinary(name) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + store.Audit(c.GetString("username"), "install_binary", name, version) + c.JSON(200, gin.H{"ok": true, "version": version}) +} + +// binaryInstallHint 获取协议的系统包安装命令 +func (s *Server) binaryInstallHint(c *gin.Context) { + name := c.Param("name") + hint := binmgr.InstallHint(name) + c.JSON(200, hint) +} + +// ---- 在线会话与流量审计 ---- + +func (s *Server) listSessions(c *gin.Context) { + c.JSON(200, sessionmgr.ListActive()) +} + +func (s *Server) listConnectionLogs(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200")) + c.JSON(200, sessionmgr.ListLogs(limit)) +} + +func (s *Server) listTrafficAlerts(c *gin.Context) { + c.JSON(200, trafficmgr.ListAlerts(50)) +} + +// disconnectSession 踢指定会话下线(标记 disconnected + 审计日志) +func (s *Server) disconnectSession(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var session model.VPNSession + if err := store.DB.First(&session, id).Error; err != nil { + c.JSON(404, gin.H{"error": "会话不存在"}) + return + } + now := time.Now() + store.DB.Model(&session).Updates(map[string]interface{}{ + "status": "disconnected", + "disconnected_at": now, + }) + sessionmgr.LogConnection(session.TunnelID, session.Username, "disconnect", session.RealAddress, + "管理员手动踢下线") + store.Audit(c.GetString("username"), "kick_session", session.Username, session.RealAddress) + c.JSON(200, gin.H{"ok": true}) +} + +// getTrafficPolicy 获取隧道的流量策略 +func (s *Server) getTrafficPolicy(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + policy := trafficmgr.GetPolicy(uint(id)) + c.JSON(200, policy) +} + +// saveTrafficPolicy 设置流量配额/限速策略 +func (s *Server) saveTrafficPolicy(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req model.TrafficPolicy + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + req.TunnelID = uint(id) + if err := trafficmgr.SavePolicy(&req); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + store.Audit(c.GetString("username"), "set_policy", fmt.Sprintf("tunnel#%d", id), + fmt.Sprintf("配额=%d 限速=%d 动作=%s", req.QuotaBytes, req.RateLimitMbps, req.ActionOnExceed)) + c.JSON(200, gin.H{"ok": true}) +} + // ---- 专线管理 ---- func (s *Server) listTunnels(c *gin.Context) { @@ -415,6 +798,8 @@ func (s *Server) updateTunnel(c *gin.Context) { if err != nil { return } + mu := lockTunnel(t.ID) + defer mu.Unlock() var req tunnelReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) @@ -486,6 +871,8 @@ func (s *Server) deleteTunnel(c *gin.Context) { if err != nil { return } + mu := lockTunnel(t.ID) + defer mu.Unlock() if p, err := s.mgr.Get(t.Protocol); err == nil { _ = p.Delete(t) } @@ -504,6 +891,8 @@ func (s *Server) startTunnel(c *gin.Context) { if err != nil { return } + mu := lockTunnel(t.ID) + defer mu.Unlock() if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) { c.JSON(400, gin.H{"error": "专线已到期,请先调整到期时间"}) return @@ -536,6 +925,8 @@ func (s *Server) stopTunnel(c *gin.Context) { if err != nil { return } + mu := lockTunnel(t.ID) + defer mu.Unlock() p, err := s.mgr.Get(t.Protocol) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) @@ -566,13 +957,14 @@ func (s *Server) downloadConfig(c *gin.Context) { c.JSON(500, gin.H{"error": err.Error()}) return } + store.Audit(c.GetString("username"), "download_config", t.Name, "下载客户端配置(可能含私钥/PSK)") ext := "conf" switch t.Protocol { case "openvpn": ext = "ovpn" case "frp": ext = "toml" - case "ipsec", "l2tp": + case "ipsec", "l2tp", "ikev2": ext = "txt" } filename := fmt.Sprintf("%s-%s.%s", t.Protocol, strings.ReplaceAll(t.Name, " ", "_"), ext) @@ -598,7 +990,126 @@ func (s *Server) tunnelStatus(c *gin.Context) { c.JSON(200, st) } -// testConnectivity 一键测试与对端网关的连通性 +// tunnelLogs 通过 SSE 实时推送专线实例日志。 +// 读取 /instances/-/instance.log,先发送尾部 100 行历史,再 tail 新增行。 +func (s *Server) tunnelLogs(c *gin.Context) { + t, err := s.mustTunnel(c) + if err != nil { + return + } + logPath := filepath.Join(service.InstanceDir(t), "instance.log") + + // SSE headers + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") // Nginx 透传 + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(500, gin.H{"error": "streaming not supported"}) + return + } + + // 如果日志文件不存在,发送提示 + if _, err := os.Stat(logPath); err != nil { + msg := "⚠ 该专线暂无日志文件(可能未启动或协议无 daemon 进程,如 WireGuard/IPSec)" + c.Writer.Write([]byte("data: " + sseEscape(msg) + "\n\n")) + flusher.Flush() + return + } + + // 1) 发送尾部历史日志(最多 100 行) + histLines := readTailLines(logPath, 100) + for _, line := range histLines { + c.Writer.Write([]byte("data: " + sseEscape(line) + "\n\n")) + } + flusher.Flush() + + // 2) 记录当前文件大小,后续只读新增部分 + fi, _ := os.Stat(logPath) + offset := int64(0) + if fi != nil { + offset = fi.Size() + } + + // 3) 轮询新增内容(每 1s),直到客户端断开 + notify := c.Writer.CloseNotify() + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-notify: + return // 客户端断开 + case <-ticker.C: + newData, newOffset, err := readFromOffset(logPath, offset) + if err != nil || len(newData) == 0 { + continue + } + offset = newOffset + for _, line := range newData { + c.Writer.Write([]byte("data: " + sseEscape(line) + "\n\n")) + } + flusher.Flush() + case <-time.After(30 * time.Second): + // 心跳保活 + c.Writer.Write([]byte(": heartbeat\n\n")) + flusher.Flush() + } + } +} + +// sseEscape 转义 SSE data 行中的换行(多行日志拆成单独的 data: 行) +func sseEscape(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + lines := strings.Split(s, "\n") + return strings.Join(lines, "\ndata: ") +} + +// readTailLines 读取文件最后 n 行 +func readTailLines(path string, n int) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + var all []string + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + all = append(all, scanner.Text()) + } + if len(all) > n { + return all[len(all)-n:] + } + return all +} + +// readFromOffset 从 offset 处读取新增内容,返回按行拆分的结果和新 offset +func readFromOffset(path string, offset int64) ([]string, int64, error) { + f, err := os.Open(path) + if err != nil { + return nil, offset, err + } + defer f.Close() + if _, err := f.Seek(offset, 0); err != nil { + return nil, offset, err + } + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + fi, _ := f.Stat() + newOffset := offset + if fi != nil { + newOffset = fi.Size() + } + return lines, newOffset, nil +} func (s *Server) testConnectivity(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { @@ -667,32 +1178,202 @@ func (s *Server) ipsecCompare(c *gin.Context) { c.JSON(200, gin.H{"consistent": len(diffs) == 0, "differences": diffs}) } -// ---- 节点管理 ---- +// ---- 节点管理(支持 SSH 凭证验证)---- + +// nodeReq 节点创建/更新请求(含明文凭证,仅传输用,不入库明文) +type nodeReq struct { + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port"` + Kind string `json:"kind"` + Username string `json:"username"` + AuthType string `json:"auth_type"` + Password string `json:"password"` + PrivateKey string `json:"private_key"` + Remark string `json:"remark"` +} + +// nodeTestReq 临时连接测试请求(不落库) +type nodeTestReq struct { + Address string `json:"address"` + Port int `json:"port"` + Username string `json:"username"` + AuthType string `json:"auth_type"` + Password string `json:"password"` + PrivateKey string `json:"private_key"` +} func (s *Server) listNodes(c *gin.Context) { var nodes []model.Node - store.DB.Find(&nodes) + store.DB.Order("kind asc, id asc").Find(&nodes) c.JSON(200, nodes) } func (s *Server) createNode(c *gin.Context) { - var n model.Node - if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" { - c.JSON(400, gin.H{"error": "节点名称必填"}) + var req nodeReq + if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" || req.Address == "" { + c.JSON(400, gin.H{"error": "节点名称与地址必填"}) return } - n.ID = 0 - store.DB.Create(&n) - store.Audit(c.GetString("username"), "create_node", n.Name, "") + if req.Port == 0 { + req.Port = 22 + } + if req.Kind == "" { + req.Kind = "remote" + } + if req.AuthType == "" { + req.AuthType = "password" + } + n := model.Node{ + Name: req.Name, Address: req.Address, Port: req.Port, Kind: req.Kind, + Username: req.Username, AuthType: req.AuthType, Remark: req.Remark, + Status: "unknown", + } + // 敏感凭证加密存储 + if req.Password != "" || req.PrivateKey != "" { + sec := map[string]interface{}{"password": req.Password, "private_key": req.PrivateKey} + raw, _ := json.Marshal(sec) + enc, err := store.EncryptSecret(string(raw)) + if err != nil { + c.JSON(500, gin.H{"error": "凭证加密失败"}) + return + } + n.Secrets = enc + } + if err := store.DB.Create(&n).Error; err != nil { + c.JSON(400, gin.H{"error": "保存失败(名称可能重复): " + err.Error()}) + return + } + store.Audit(c.GetString("username"), "create_node", n.Name, fmt.Sprintf("%s:%d", n.Address, n.Port)) c.JSON(200, n) } +func (s *Server) updateNode(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var n model.Node + if err := store.DB.First(&n, id).Error; err != nil { + c.JSON(404, gin.H{"error": "节点不存在"}) + return + } + var req nodeReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + if req.Name != "" { + n.Name = req.Name + } + if req.Address != "" { + n.Address = req.Address + } + if req.Port > 0 { + n.Port = req.Port + } + if req.Kind != "" { + n.Kind = req.Kind + } + n.Username = req.Username + if req.AuthType != "" { + n.AuthType = req.AuthType + } + n.Remark = req.Remark + // 仅当传入了新凭证时更新(空值表示保持不变) + if req.Password != "" || req.PrivateKey != "" { + sec := map[string]interface{}{"password": req.Password, "private_key": req.PrivateKey} + raw, _ := json.Marshal(sec) + enc, err := store.EncryptSecret(string(raw)) + if err != nil { + c.JSON(500, gin.H{"error": "凭证加密失败"}) + return + } + n.Secrets = enc + n.Status = "unknown" // 凭证变更后重置状态 + } + store.DB.Save(&n) + store.Audit(c.GetString("username"), "update_node", n.Name, "") + c.JSON(200, n) +} + +// testNodeConnection 临时凭证测试(不落库),用于添加节点对话框的即时验证 +func (s *Server) testNodeConnection(c *gin.Context) { + var req nodeTestReq + if err := c.ShouldBindJSON(&req); err != nil || req.Address == "" { + c.JSON(400, gin.H{"error": "地址必填"}) + return + } + result := sshutil.TestConnection(sshutil.Config{ + Host: req.Address, Port: req.Port, User: req.Username, + AuthType: req.AuthType, Password: req.Password, KeyData: req.PrivateKey, + }) + c.JSON(200, result) +} + +// verifyNode 用已存节点的加密凭证做 SSH 测试,更新 status +func (s *Server) verifyNode(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var n model.Node + if err := store.DB.First(&n, id).Error; err != nil { + c.JSON(404, gin.H{"error": "节点不存在"}) + return + } + if n.Kind == "local" { + c.JSON(200, gin.H{"success": true, "message": "本机节点,无需 SSH 验证"}) + return + } + // 解密凭证 + password, privateKey := decodeNodeSecrets(n.Secrets) + result := sshutil.TestConnection(sshutil.Config{ + Host: n.Address, Port: n.Port, User: n.Username, + AuthType: n.AuthType, Password: password, KeyData: privateKey, + }) + if result.Success { + n.Status = "verified" + } else { + n.Status = "failed" + } + store.DB.Model(&n).Update("status", n.Status) + store.Audit(c.GetString("username"), "verify_node", n.Name, fmt.Sprintf("success=%v", result.Success)) + c.JSON(200, result) +} + func (s *Server) deleteNode(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) + var n model.Node + if err := store.DB.First(&n, id).Error; err != nil { + c.JSON(404, gin.H{"error": "节点不存在"}) + return + } + // 检查是否有专线引用该节点 + var refCount int64 + store.DB.Model(&model.Tunnel{}).Where("node_id = ?", id).Count(&refCount) + if refCount > 0 { + c.JSON(400, gin.H{"error": fmt.Sprintf("该节点被 %d 条专线引用,请先解除关联", refCount)}) + return + } store.DB.Delete(&model.Node{}, id) + store.Audit(c.GetString("username"), "delete_node", n.Name, "") c.JSON(200, gin.H{"ok": true}) } +// decodeNodeSecrets 解密节点的 SSH 凭证 JSON +func decodeNodeSecrets(enc string) (password, privateKey string) { + plain, err := store.DecryptSecret(enc) + if err != nil || plain == "" { + return "", "" + } + var sec map[string]interface{} + if json.Unmarshal([]byte(plain), &sec) != nil { + return "", "" + } + if v, ok := sec["password"].(string); ok { + password = v + } + if v, ok := sec["private_key"].(string); ok { + privateKey = v + } + return +} + // ---- 自定义协议插件 ---- func (s *Server) listPlugins(c *gin.Context) { diff --git a/backend/internal/api/identity.go b/backend/internal/api/identity.go index ed8947c..20234cb 100644 --- a/backend/internal/api/identity.go +++ b/backend/internal/api/identity.go @@ -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), + }) +} diff --git a/backend/internal/binmgr/binmgr.go b/backend/internal/binmgr/binmgr.go new file mode 100644 index 0000000..f6aaa8a --- /dev/null +++ b/backend/internal/binmgr/binmgr.go @@ -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 +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 1f1677b..ad676aa 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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 +} diff --git a/backend/internal/monitor/monitor.go b/backend/internal/monitor/monitor.go index eb77737..2dea1bb 100644 --- a/backend/internal/monitor/monitor.go +++ b/backend/internal/monitor/monitor.go @@ -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, "专线到期自动停止") } } } diff --git a/backend/internal/service/common.go b/backend/internal/service/common.go index 7e79572..840d181 100644 --- a/backend/internal/service/common.go +++ b/backend/internal/service/common.go @@ -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 { diff --git a/backend/internal/service/frp/frp.go b/backend/internal/service/frp/frp.go index 0f989a5..6bfd0ad 100644 --- a/backend/internal/service/frp/frp.go +++ b/backend/internal/service/frp/frp.go @@ -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 } diff --git a/backend/internal/service/ikev2/ikev2.go b/backend/internal/service/ikev2/ikev2.go new file mode 100644 index 0000000..d782463 --- /dev/null +++ b/backend/internal/service/ikev2/ikev2.go @@ -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 +} diff --git a/backend/internal/service/ikev2/ikev2_test.go b/backend/internal/service/ikev2/ikev2_test.go new file mode 100644 index 0000000..4b36f87 --- /dev/null +++ b/backend/internal/service/ikev2/ikev2_test.go @@ -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") + } +} diff --git a/backend/internal/service/ipsec/ipsec.go b/backend/internal/service/ipsec/ipsec.go index e3caccc..373670b 100644 --- a/backend/internal/service/ipsec/ipsec.go +++ b/backend/internal/service/ipsec/ipsec.go @@ -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 { diff --git a/backend/internal/service/l2tp/l2tp.go b/backend/internal/service/l2tp/l2tp.go index b71bf5b..c633bda 100644 --- a/backend/internal/service/l2tp/l2tp.go +++ b/backend/internal/service/l2tp/l2tp.go @@ -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) diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go index 34c2421..1d2a777 100644 --- a/backend/internal/service/openvpn/openvpn.go +++ b/backend/internal/service/openvpn/openvpn.go @@ -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("\n" + 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:: + 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 +} diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go index 0d75bec..0c4df3a 100644 --- a/backend/internal/service/openvpn/openvpn_test.go +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -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) { diff --git a/backend/internal/service/wireguard/wireguard.go b/backend/internal/service/wireguard/wireguard.go index 4f32058..5901b51 100644 --- a/backend/internal/service/wireguard/wireguard.go +++ b/backend/internal/service/wireguard/wireguard.go @@ -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) diff --git a/backend/internal/service/wireguard/wireguard_test.go b/backend/internal/service/wireguard/wireguard_test.go index 21f62fb..89b9af2 100644 --- a/backend/internal/service/wireguard/wireguard_test.go +++ b/backend/internal/service/wireguard/wireguard_test.go @@ -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", diff --git a/backend/internal/sessionmgr/sessionmgr.go b/backend/internal/sessionmgr/sessionmgr.go new file mode 100644 index 0000000..34e3137 --- /dev/null +++ b/backend/internal/sessionmgr/sessionmgr.go @@ -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)) +} diff --git a/backend/internal/sshutil/sshutil.go b/backend/internal/sshutil/sshutil.go new file mode 100644 index 0000000..184df0b --- /dev/null +++ b/backend/internal/sshutil/sshutil.go @@ -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) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index b15d94b..1eb670c 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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 } diff --git a/backend/internal/trafficmgr/trafficmgr.go b/backend/internal/trafficmgr/trafficmgr.go new file mode 100644 index 0000000..56cd5f7 --- /dev/null +++ b/backend/internal/trafficmgr/trafficmgr.go @@ -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 +} diff --git a/backend/main.go b/backend/main.go index cce8758..3d8a34a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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()) diff --git a/frontend/src/api.js b/frontend/src/api.js index 29ac109..2dbc141 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) } diff --git a/frontend/src/components/ProtocolIcon.vue b/frontend/src/components/ProtocolIcon.vue new file mode 100644 index 0000000..7df2161 --- /dev/null +++ b/frontend/src/components/ProtocolIcon.vue @@ -0,0 +1,73 @@ + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index 257f0f4..bc7ed8f 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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) diff --git a/frontend/src/protocols.js b/frontend/src/protocols.js index 75ab3b9..05a401a 100644 --- a/frontend/src/protocols.js +++ b/frontend/src/protocols.js @@ -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 +} diff --git a/frontend/src/router.js b/frontend/src/router.js index 9c344a1..4b3cfc4 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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: '页面不存在' } } ] } ] diff --git a/frontend/src/style.css b/frontend/src/style.css index 66d0d53..0f412c1 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..2ad8c88 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -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; } +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index ca865bd..4f30b31 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,27 +1,49 @@ - + @@ -49,24 +55,35 @@ - + - + @@ -89,26 +163,80 @@

使用 WireGuard App「从二维码添加」

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {{ logStreaming ? '● 实时输出中' : '已停止' }} + + 暂停 + 继续 + 清屏 +
+
{{ logContent || '等待日志输出...' }}
+
diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index 6764827..6412b8a 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -1,23 +1,55 @@ + diff --git a/frontend/src/views/VPNUsers.vue b/frontend/src/views/VPNUsers.vue index b043317..b6de2d7 100644 --- a/frontend/src/views/VPNUsers.vue +++ b/frontend/src/views/VPNUsers.vue @@ -1,29 +1,417 @@ +