feat: 新增 GRE 隧道协议,管理端口调整为 18080

- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2
- backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动
- backend: OpenVPN 认证辅助与用户口令同步逻辑完善
- frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行)
- frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化
- i18n: 中英文文案同步更新
- test: 新增 binmgr/common/gre 单测,openvpn 测试补充
- docs: README 补充 GRE 说明,默认监听端口 8080 → 18080
- chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
This commit is contained in:
opsx committed 2026-10-20 20:58:58 +08:00
1 parent c7f59f2cbf
commit ef7eb378d2
28 files changed
+1363 -439

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
# 构建产物 # 构建产物
opsx-vpn opsx-vpn
opsx-vpn.previous
backend/dist/ backend/dist/
frontend/dist/ frontend/dist/
+12 -7
View File
@@ -11,6 +11,7 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
| IKEv2 远程接入 | StrongSwan Road Warrior,PSK + EAP-MSCHAPv2 | 员工手机/笔记本原生拨号 | | IKEv2 远程接入 | StrongSwan Road Warrior,PSK + EAP-MSCHAPv2 | 员工手机/笔记本原生拨号 |
| L2TP/IPSec | StrongSwan + xl2tpd,支持多拨号用户 | 手机/老设备兼容 | | L2TP/IPSec | StrongSwan + xl2tpd,支持多拨号用户 | 手机/老设备兼容 |
| FRP | 直接管理 `frps` / `frpc`(TOML),含 STCP/XTCP | 内网穿透、端口映射 | | FRP | 直接管理 `frps` / `frpc`(TOML),含 STCP/XTCP | 内网穿透、端口映射 |
| GRE | Linux `ip tunnel` 管理标准 GRE 隧道 | 点对点网站互联 |
| OpenVPN | 进程管理 + PKI;UDP/TCP 可切换;证书/密码 + TOTP MFA | 老项目兼容 | | OpenVPN | 进程管理 + PKI;UDP/TCP 可切换;证书/密码 + TOTP MFA | 老项目兼容 |
| 自定义协议 | 插件命令钩子(Create/Start/Stop/Status/Config) | 任意协议扩展 | | 自定义协议 | 插件命令钩子(Create/Start/Stop/Status/Config) | 任意协议扩展 |
@@ -52,11 +53,11 @@ scp opsx-vpn user@server:/opt/opsx-vpn/
cd /opt/opsx-vpn && chmod +x opsx-vpn cd /opt/opsx-vpn && chmod +x opsx-vpn
# 3. 安装系统依赖(按需) # 3. 安装系统依赖(按需)
apt install -y wireguard-tools openvpn strongswan strongswan-swanctl iptables apt install -y wireguard-tools openvpn strongswan strongswan-swanctl iproute2 iptables
# FRP 无需系统包 —— 平台 UI 内点「下载部署」自动从 GitHub 下载到 data/bin/ # FRP 无需系统包 —— 平台 UI 内点「下载部署」自动从 GitHub 下载到 data/bin/
# 4. 启动(生产建议用 systemd 管理) # 4. 启动(生产建议用 systemd 管理)
./opsx-vpn # 监听 :8080,数据目录 ./data VPN_LISTEN_ADDR=:18080 ./opsx-vpn # 生产管理端口 :18080,数据目录 ./data
``` ```
### systemd 服务(生产推荐) ### systemd 服务(生产推荐)
@@ -74,7 +75,7 @@ ExecStart=/opt/opsx-vpn/opsx-vpn
Restart=always Restart=always
RestartSec=5 RestartSec=5
# 环境变量按需调整 # 环境变量按需调整
Environment=VPN_LISTEN_ADDR=:8080 Environment=VPN_LISTEN_ADDR=:18080
Environment=VPN_DATA_DIR=/opt/opsx-vpn/data Environment=VPN_DATA_DIR=/opt/opsx-vpn/data
# WireGuard/OpenVPN 需要 root 权限(创建 TUN 设备 + iptables) # WireGuard/OpenVPN 需要 root 权限(创建 TUN 设备 + iptables)
# 如需降权,需配置 NET_ADMIN capability # 如需降权,需配置 NET_ADMIN capability
@@ -104,12 +105,13 @@ journalctl -u opsx-vpn -f # 查看日志
| UDP 4500 | IPSec / IKEv2 / L2TP | UDP | NAT-T 穿透 | | UDP 4500 | IPSec / IKEv2 / L2TP | UDP | NAT-T 穿透 |
| UDP 1701 | L2TP | UDP | L2TP 隧道 | | UDP 1701 | L2TP | UDP | L2TP 隧道 |
| TCP 7000 | FRP | TCP | frps 绑定端口(客户端连接用) | | TCP 7000 | FRP | TCP | frps 绑定端口(客户端连接用) |
| IP 协议 47 | GRE | GRE | GRE 封装(无 TCP/UDP 端口) |
### 管理(仅限你的 IP 或内网) ### 管理(仅限你的 IP 或内网)
| 端口 | 协议 | 类型 | 用途 | | 端口 | 协议 | 类型 | 用途 |
|---|---|---|---| |---|---|---|---|
| TCP 8080 | 管理平台 | TCP | Web UI + API(**勿对公网开放**,建议 Nginx HTTPS 后改 443) | | TCP 18080 | 管理平台 | TCP | Web UI + API(**勿对公网开放**,建议 Nginx HTTPS 后改 443) |
| TCP 22 | SSH | TCP | 远程管理 | | TCP 22 | SSH | TCP | 远程管理 |
### 安全组配置示例 ### 安全组配置示例
@@ -122,13 +124,15 @@ UDP 1194 0.0.0.0/0 # OpenVPN UDP
TCP 1194 0.0.0.0/0 # OpenVPN TCP(如启用) TCP 1194 0.0.0.0/0 # OpenVPN TCP(如启用)
UDP 1701 0.0.0.0/0 # L2TP UDP 1701 0.0.0.0/0 # L2TP
TCP 7000 0.0.0.0/0 # FRP server TCP 7000 0.0.0.0/0 # FRP server
# GRE 不是 TCP/UDP 端口,需在安全组中放行 GRE / IP 协议 47
GRE 协议 47 对端公网 IP/32 # 建议只允许对端地址
# 管理(仅限你的 IP) # 管理(仅限你的 IP)
TCP 8080 你的IP/32 # 管理 UI TCP 18080 你的IP/32 # 管理 UI
TCP 22 你的IP/32 # SSH TCP 22 你的IP/32 # SSH
``` ```
> **注意**:IPSec 必须同时开放 UDP 500 + 4500(缺一个连不上)。管理端口 8080 切勿对公网开放。仅用 WireGuard 最简部署只需 3 个端口:UDP 51820 + TCP 8080 + TCP 22。 > **注意**:IPSec 必须同时开放 UDP 500 + 4500(缺一个连不上)。GRE 需要**本端、对端及中间云安全组**双向允许 **IP 协议 47**,不是端口号;Linux 端依赖 `iproute2` 的 `ip tunnel`。管理端口 18080 切勿对公网开放。仅用 WireGuard 最简部署只需 3 个端口:UDP 51820 + TCP 18080 + TCP 22。
## 协议依赖安装 ## 协议依赖安装
@@ -137,6 +141,7 @@ TCP 22 你的IP/32 # SSH
| WireGuard | `apt install -y wireguard-tools` | ❌ 系统包 | | WireGuard | `apt install -y wireguard-tools` | ❌ 系统包 |
| OpenVPN | `apt install -y openvpn` | ✅ GitHub Release / 系统包 | | OpenVPN | `apt install -y openvpn` | ✅ GitHub Release / 系统包 |
| StrongSwan (IPSec/IKEv2/L2TP) | `apt install -y strongswan strongswan-swanctl` | ❌ 系统包 | | StrongSwan (IPSec/IKEv2/L2TP) | `apt install -y strongswan strongswan-swanctl` | ❌ 系统包 |
| GRE | `apt install -y iproute2`(多数系统默认已安装) | ❌ 系统包 |
| FRP | 无需系统包 | ✅ 平台 UI「下载部署」自动从 GitHub 下载 | | FRP | 无需系统包 | ✅ 平台 UI「下载部署」自动从 GitHub 下载 |
在「总览 → 协议引擎状态」表中,未就绪的协议会显示「下载部署」或「安装命令」按钮。 在「总览 → 协议引擎状态」表中,未就绪的协议会显示「下载部署」或「安装命令」按钮。
@@ -145,7 +150,7 @@ TCP 22 你的IP/32 # SSH
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
|---|---|---| |---|---|---|
| `VPN_LISTEN_ADDR` | `:8080` | 监听地址 | | `VPN_LISTEN_ADDR` | `:18080` | 监听地址 |
| `VPN_DATA_DIR` | `./data` | 数据目录(SQLite、密钥、实例配置、下载的二进制) | | `VPN_DATA_DIR` | `./data` | 数据目录(SQLite、密钥、实例配置、下载的二进制) |
## 常用命令 ## 常用命令
+120 -68
View File
@@ -31,14 +31,14 @@ import (
"vpn-platform/backend/internal/sessionmgr" "vpn-platform/backend/internal/sessionmgr"
"vpn-platform/backend/internal/sshutil" "vpn-platform/backend/internal/sshutil"
"vpn-platform/backend/internal/store" "vpn-platform/backend/internal/store"
"vpn-platform/backend/internal/trafficmgr"
"vpn-platform/backend/internal/taskmgr" "vpn-platform/backend/internal/taskmgr"
"vpn-platform/backend/internal/trafficmgr"
) )
// Server API 服务器 // Server API 服务器
type Server struct { type Server struct {
mgr *core.Manager mgr *core.Manager
taskMgr *taskmgr.Manager taskMgr *taskmgr.Manager
} }
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} } func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} }
@@ -227,6 +227,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
// 只读 // 只读
auth.GET("/overview", s.overview) auth.GET("/overview", s.overview)
auth.GET("/readiness", s.readiness)
auth.GET("/protocols", s.listProtocols) auth.GET("/protocols", s.listProtocols)
auth.GET("/tunnels", s.listTunnels) auth.GET("/tunnels", s.listTunnels)
auth.GET("/tunnels/:id", s.getTunnel) auth.GET("/tunnels/:id", s.getTunnel)
@@ -292,6 +293,8 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
admin.POST("/sessions/:id/disconnect", s.disconnectSession) admin.POST("/sessions/:id/disconnect", s.disconnectSession)
admin.GET("/vpn-users", s.listVPNUsers) admin.GET("/vpn-users", s.listVPNUsers)
admin.POST("/vpn-users", s.createVPNUser) admin.POST("/vpn-users", s.createVPNUser)
admin.POST("/vpn-users/import-platform", s.importPlatformVPNUsers)
admin.POST("/vpn-users/batch-delete", s.batchDeleteVPNUsers)
admin.PUT("/vpn-users/:id", s.updateVPNUser) admin.PUT("/vpn-users/:id", s.updateVPNUser)
admin.DELETE("/vpn-users/:id", s.deleteVPNUser) admin.DELETE("/vpn-users/:id", s.deleteVPNUser)
admin.POST("/vpn-users/:id/mfa/setup", s.vpnUserMFASetup) admin.POST("/vpn-users/:id/mfa/setup", s.vpnUserMFASetup)
@@ -600,11 +603,26 @@ func (s *Server) overview(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"total": total, "running": running, "nodes": nodeCount, "plugins": pluginCount, "total": total, "running": running, "nodes": nodeCount, "plugins": pluginCount,
"online_sessions": sessionmgr.CountActive(), "online_sessions": sessionmgr.CountActive(),
"rx_bytes": sum.Rx, "tx_bytes": sum.Tx, "rx_bytes": sum.Rx, "tx_bytes": sum.Tx,
"protocol_distribution": dist, "expiring": expiring, "protocol_distribution": dist, "expiring": expiring,
}) })
} }
// readiness reports host prerequisites that directly affect VPN forwarding.
func (s *Server) readiness(c *gin.Context) {
forwarding := false
if raw, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward"); err == nil {
forwarding = strings.TrimSpace(string(raw)) == "1"
}
iface, ifaceErr := service.DefaultRouteInterface()
c.JSON(200, gin.H{
"root": os.Geteuid() == 0,
"ipv4_forwarding": forwarding,
"default_interface": iface,
"default_route_ready": ifaceErr == nil,
})
}
func (s *Server) listProtocols(c *gin.Context) { func (s *Server) listProtocols(c *gin.Context) {
type item struct { type item struct {
Name string `json:"name"` Name string `json:"name"`
@@ -836,11 +854,14 @@ func (s *Server) getTunnel(c *gin.Context) {
return return
} }
// 客户端配置预览 // 客户端配置预览
var clientConf string var clientConf, clientConfError string
if p, err := s.mgr.Get(t.Protocol); err == nil { if p, err := s.mgr.Get(t.Protocol); err == nil {
clientConf, _ = p.GenerateClientConfig(t) clientConf, err = p.GenerateClientConfig(t)
if err != nil {
clientConfError = err.Error()
}
} }
c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf}) c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf, "client_config_error": clientConfError})
} }
func (s *Server) updateTunnel(c *gin.Context) { func (s *Server) updateTunnel(c *gin.Context) {
@@ -855,63 +876,94 @@ func (s *Server) updateTunnel(c *gin.Context) {
c.JSON(400, gin.H{"error": "参数错误"}) c.JSON(400, gin.H{"error": "参数错误"})
return return
} }
var ( hasCfg := req.Config != nil
p core.Protocol if !hasCfg {
newRaw string if req.Name != "" {
hasCfg = req.Config != nil t.Name = req.Name
wasRunning = t.State == "running" }
) t.Remark, t.AutoStart, t.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt
if hasCfg { if err := store.DB.Model(t).Updates(map[string]interface{}{
var err error "name": t.Name, "remark": t.Remark, "auto_start": t.AutoStart, "expire_at": t.ExpireAt,
p, err = s.mgr.Get(t.Protocol) }).Error; err != nil {
if err != nil { c.JSON(500, gin.H{"error": "保存失败: " + err.Error()})
c.JSON(400, gin.H{"error": err.Error()})
return return
} }
cfg, err := p.Validate(req.Config) store.Audit(c.GetString("username"), "update_tunnel", t.Name, "")
if err != nil { c.JSON(200, t)
c.JSON(400, gin.H{"error": err.Error()})
return
}
raw, _ := json.Marshal(cfg)
newRaw = string(raw)
if wasRunning {
if err := p.Stop(t); err != nil {
c.JSON(500, gin.H{"error": "停止旧配置失败: " + err.Error()})
return
}
}
}
if req.Name != "" {
t.Name = req.Name
}
t.Remark = req.Remark
t.AutoStart = req.AutoStart
t.ExpireAt = req.ExpireAt
// 不通过 Save(t) 写入 Config,避免更新请求中的密钥明文落库。
if err := store.DB.Model(t).Updates(map[string]interface{}{
"name": t.Name, "remark": t.Remark, "auto_start": t.AutoStart, "expire_at": t.ExpireAt,
}).Error; err != nil {
c.JSON(500, gin.H{"error": "保存失败: " + err.Error()})
return return
} }
if hasCfg {
t.Config = newRaw p, err := s.mgr.Get(t.Protocol)
if err := p.Create(t); err != nil { if err != nil {
c.JSON(500, gin.H{"error": "生成配置失败: " + err.Error()}) c.JSON(400, gin.H{"error": err.Error()})
return return
}
cfg, err := p.Validate(req.Config)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
raw, _ := json.Marshal(cfg)
original := *t
candidate := original
candidate.Config = string(raw)
if req.Name != "" {
candidate.Name = req.Name
}
candidate.Remark, candidate.AutoStart, candidate.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt
wasRunning := original.State == "running"
backup, err := service.SnapshotInstance(&original)
if err != nil {
c.JSON(500, gin.H{"error": "无法备份旧配置,已取消更新: " + err.Error()})
return
}
defer service.CleanupInstanceSnapshot(backup)
rollback := func(cause error) error {
_ = service.RestoreInstanceSnapshot(&original, backup)
if err := store.DB.Save(&original).Error; err != nil {
return fmt.Errorf("恢复数据库配置失败: %w", err)
} }
if wasRunning { if wasRunning {
if err := p.Start(t); err != nil { if err := p.Start(&original); err != nil {
t.State = "error" return fmt.Errorf("恢复旧服务失败: %w", err)
t.LastError = err.Error()
} else {
t.State = "running"
t.LastError = ""
} }
store.DB.Model(t).Updates(map[string]interface{}{"state": t.State, "last_error": t.LastError}) }
*t = original
return cause
}
if wasRunning {
if err := p.Stop(&original); err != nil {
c.JSON(500, gin.H{"error": "停止旧配置失败: " + err.Error()})
return
} }
} }
if err := p.Create(&candidate); err != nil {
if restoreErr := rollback(err); restoreErr != err {
c.JSON(500, gin.H{"error": "生成新配置失败,且回滚异常: " + restoreErr.Error()})
return
}
c.JSON(500, gin.H{"error": "生成新配置失败,已恢复旧配置: " + err.Error()})
return
}
if wasRunning {
if err := p.Start(&candidate); err != nil {
if restoreErr := rollback(err); restoreErr != err {
c.JSON(500, gin.H{"error": "启动新配置失败,且回滚异常: " + restoreErr.Error()})
return
}
c.JSON(500, gin.H{"error": "启动新配置失败,已恢复旧服务: " + err.Error()})
return
}
candidate.State, candidate.LastError = "running", ""
if err := store.DB.Model(&candidate).Updates(map[string]interface{}{"state": candidate.State, "last_error": ""}).Error; err != nil {
_ = rollback(err)
c.JSON(500, gin.H{"error": "保存运行状态失败,已恢复旧服务: " + err.Error()})
return
}
}
*t = candidate
store.Audit(c.GetString("username"), "update_tunnel", t.Name, "") store.Audit(c.GetString("username"), "update_tunnel", t.Name, "")
c.JSON(200, t) c.JSON(200, t)
} }
@@ -1063,11 +1115,11 @@ func (s *Server) tunnelTrafficHistory(c *gin.Context) {
since := time.Now().Add(-time.Duration(hours) * time.Hour) since := time.Now().Add(-time.Duration(hours) * time.Hour)
type row struct { type row struct {
Time time.Time `json:"time"` Time time.Time `json:"time"`
Rx int64 `json:"rx"` Rx int64 `json:"rx"`
Tx int64 `json:"tx"` Tx int64 `json:"tx"`
DeltaRx int64 `json:"delta_rx"` DeltaRx int64 `json:"delta_rx"`
DeltaTx int64 `json:"delta_tx"` DeltaTx int64 `json:"delta_tx"`
} }
var results []row var results []row
@@ -1338,15 +1390,15 @@ func (s *Server) ipsecCompare(c *gin.Context) {
// nodeReq 节点创建/更新请求(含明文凭证,仅传输用,不入库明文) // nodeReq 节点创建/更新请求(含明文凭证,仅传输用,不入库明文)
type nodeReq struct { type nodeReq struct {
Name string `json:"name"` Name string `json:"name"`
Address string `json:"address"` Address string `json:"address"`
Port int `json:"port"` Port int `json:"port"`
Kind string `json:"kind"` Kind string `json:"kind"`
Username string `json:"username"` Username string `json:"username"`
AuthType string `json:"auth_type"` AuthType string `json:"auth_type"`
Password string `json:"password"` Password string `json:"password"`
PrivateKey string `json:"private_key"` PrivateKey string `json:"private_key"`
Remark string `json:"remark"` Remark string `json:"remark"`
} }
// nodeTestReq 临时连接测试请求(不落库) // nodeTestReq 临时连接测试请求(不落库)
+166 -16
View File
@@ -419,16 +419,17 @@ func (s *Server) listVPNUsers(c *gin.Context) {
} }
func (s *Server) createVPNUser(c *gin.Context) { func (s *Server) createVPNUser(c *gin.Context) {
var req struct { var req struct {
TunnelID uint `json:"tunnel_id"` TunnelID uint `json:"tunnel_id"`
Username string `json:"username"` PlatformUserID uint `json:"platform_user_id"`
DisplayName string `json:"display_name"` Username string `json:"username"`
Email string `json:"email"` DisplayName string `json:"display_name"`
Source string `json:"source"` Email string `json:"email"`
LDAPDN string `json:"ldap_dn"` Source string `json:"source"`
Password string `json:"password"` LDAPDN string `json:"ldap_dn"`
Enabled *bool `json:"enabled"` Password string `json:"password"`
Enabled *bool `json:"enabled"`
} }
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || req.Username == "" { if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || (req.Username == "" && req.PlatformUserID == 0) {
c.JSON(400, gin.H{"error": "隧道和用户名必填"}) c.JSON(400, gin.H{"error": "隧道和用户名必填"})
return return
} }
@@ -439,6 +440,12 @@ func (s *Server) createVPNUser(c *gin.Context) {
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"}) c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
return return
} }
// 手工创建仅支持独立本地拨号账号。LDAP 身份必须从用户管理批量导入,
// 这样可以复用平台身份源,并由服务端查找、固定对应的 LDAP DN。
if req.PlatformUserID == 0 && req.Source == "ldap" {
c.JSON(400, gin.H{"error": "LDAP 用户请通过“批量导入”从用户管理导入"})
return
}
t, err := store.GetTunnel(req.TunnelID) t, err := store.GetTunnel(req.TunnelID)
if err != nil { if err != nil {
c.JSON(404, gin.H{"error": "隧道不存在"}) c.JSON(404, gin.H{"error": "隧道不存在"})
@@ -450,20 +457,55 @@ func (s *Server) createVPNUser(c *gin.Context) {
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"}) c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
return return
} }
if req.PlatformUserID != 0 {
if t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "平台用户导入仅支持 OpenVPN;L2TP/IKEv2 需要独立拨号密码"})
return
}
var account model.User
if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil {
c.JSON(404, gin.H{"error": "平台用户不存在"})
return
}
if !account.Enabled {
c.JSON(400, gin.H{"error": "平台用户已禁用,无法导入"})
return
}
req.Username, req.Email, req.Source = account.Username, account.Email, account.Source
if req.DisplayName == "" {
req.DisplayName = account.Username
}
if req.Source == "ldap" {
dn, err := findLDAPUserDN(account.Username)
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 平台用户导入失败: " + err.Error()})
return
}
req.LDAPDN = dn
}
}
user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true} 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 { if req.Enabled != nil {
user.Enabled = *req.Enabled user.Enabled = *req.Enabled
} }
if user.Source == "local" { if user.Source == "local" {
if len(req.Password) < 8 { if req.PlatformUserID != 0 {
var account model.User
if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil || account.Password == "" {
c.JSON(400, gin.H{"error": "平台本地用户密码不可用"})
return
}
user.Password = account.Password
} else if len(req.Password) < 8 {
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"}) c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
return return
} } else {
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash) user.Password = string(hash)
// L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。 // L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。
if enc, err := store.EncryptSecret(req.Password); err == nil { if enc, err := store.EncryptSecret(req.Password); err == nil {
user.DialSecret = enc user.DialSecret = enc
}
} }
} else if user.LDAPDN == "" { } else if user.LDAPDN == "" {
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"}) c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
@@ -476,6 +518,114 @@ func (s *Server) createVPNUser(c *gin.Context) {
_ = refreshDialAuth(t) _ = refreshDialAuth(t)
c.JSON(200, user) c.JSON(200, user)
} }
// importPlatformVPNUsers imports selected platform local/LDAP accounts into an
// OpenVPN tunnel. L2TP/IKEv2 use CHAP/EAP and require a dedicated dial-in
// secret, so they intentionally remain manual-user only.
func (s *Server) importPlatformVPNUsers(c *gin.Context) {
var req struct {
TunnelID uint `json:"tunnel_id"`
PlatformUserIDs []uint `json:"platform_user_ids"`
}
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || len(req.PlatformUserIDs) == 0 {
c.JSON(400, gin.H{"error": "请选择目标 OpenVPN 隧道和至少一个平台用户"})
return
}
t, err := store.GetTunnel(req.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "隧道不存在"})
return
}
if t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "平台用户批量导入仅支持 OpenVPN;L2TP/IKEv2 请创建独立拨号账号"})
return
}
var accounts []model.User
if err := store.DB.Where("id IN ? AND enabled = ?", req.PlatformUserIDs, true).Find(&accounts).Error; err != nil {
c.JSON(500, gin.H{"error": "读取平台用户失败: " + err.Error()})
return
}
created, skipped := 0, []string{}
for _, account := range accounts {
var exists int64
store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND username = ?", t.ID, account.Username).Count(&exists)
if exists > 0 {
skipped = append(skipped, account.Username+"(已存在)")
continue
}
user := model.VPNUser{TunnelID: t.ID, Username: account.Username, DisplayName: account.Username, Email: account.Email, Source: account.Source, Enabled: true}
if account.Source == "ldap" {
dn, err := findLDAPUserDN(account.Username)
if err != nil {
skipped = append(skipped, account.Username+"(LDAP DN 获取失败)")
continue
}
user.LDAPDN = dn
} else {
user.Password = account.Password
}
if err := store.DB.Create(&user).Error; err != nil {
skipped = append(skipped, account.Username+"(创建失败)")
continue
}
created++
}
store.Audit(c.GetString("username"), "import_vpn_users", t.Name, fmt.Sprintf("成功 %d,跳过 %d", created, len(skipped)))
c.JSON(200, gin.H{"created": created, "skipped": skipped})
}
func (s *Server) batchDeleteVPNUsers(c *gin.Context) {
var req struct {
IDs []uint `json:"ids"`
}
if c.ShouldBindJSON(&req) != nil || len(req.IDs) == 0 {
c.JSON(400, gin.H{"error": "请选择要删除的拨号用户"})
return
}
var users []model.VPNUser
store.DB.Where("id IN ?", req.IDs).Find(&users)
tunnels := map[uint]bool{}
for _, user := range users {
revokeOpenVPNCert(&user)
tunnels[user.TunnelID] = true
}
if err := store.DB.Delete(&model.VPNUser{}, req.IDs).Error; err != nil {
c.JSON(500, gin.H{"error": "批量删除失败: " + err.Error()})
return
}
for tunnelID := range tunnels {
if t, err := store.GetTunnel(tunnelID); err == nil {
_ = refreshDialAuth(t)
}
}
store.Audit(c.GetString("username"), "batch_delete_vpn_users", "VPN 拨号用户", fmt.Sprintf("删除 %d 个", len(users)))
c.JSON(200, gin.H{"deleted": len(users)})
}
// findLDAPUserDN resolves a platform LDAP account to the DN required by the
// OpenVPN authentication helper. The password is never read or stored here.
func findLDAPUserDN(username string) (string, error) {
cfg, err := ldapSettings()
if err != nil {
return "", err
}
conn, err := ldapConnect(cfg)
if err != nil {
return "", err
}
defer conn.Close()
filter := cfg.UserFilter
if strings.Contains(filter, "%s") {
filter = fmt.Sprintf(filter, ldap.EscapeFilter(username))
} else {
filter = fmt.Sprintf("(&%s(%s=%s))", filter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
}
result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn"}, nil))
if err != nil || len(result.Entries) != 1 {
return "", fmt.Errorf("未找到 LDAP 用户 %q", username)
}
return result.Entries[0].DN, nil
}
func (s *Server) updateVPNUser(c *gin.Context) { func (s *Server) updateVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser var user model.VPNUser
+23 -20
View File
@@ -381,34 +381,32 @@ func InstallHint(protocol string) map[string]interface{} {
"pacman": "pacman -S --noconfirm wireguard-tools", "pacman": "pacman -S --noconfirm wireguard-tools",
}, },
"openvpn": { "openvpn": {
"apt-get": "apt-get install -y openvpn", "apt-get": "apt-get install -y openvpn easy-rsa",
"yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y openvpn", epelMirror), "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y openvpn easy-rsa", epelMirror),
"dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y openvpn", epelMirror), "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y openvpn easy-rsa", epelMirror),
"apk": "apk add openvpn", "apk": "apk add openvpn easy-rsa",
"pacman": "pacman -S --noconfirm openvpn", "pacman": "pacman -S --noconfirm openvpn easy-rsa",
}, },
"strongswan": { "strongswan": {
"apt-get": "apt-get install -y strongswan strongswan-swanctl", "apt-get": "apt-get install -y strongswan strongswan-swanctl",
"yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y strongswan", epelMirror), "yum": "yum install -y strongswan",
"dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y strongswan", epelMirror), "dnf": "dnf install -y strongswan",
"apk": "apk add strongswan", "apk": "apk add strongswan",
"pacman": "pacman -S --noconfirm strongswan", "pacman": "pacman -S --noconfirm strongswan",
}, },
} }
// L2TP 额外需要 xl2tpd // L2TP 额外需要 xl2tpd。TencentOS 4 的 EPOL 没有该包,需回退到官方源码编译。
if protocol == "l2tp" || protocol == "strongswan" { if protocol == "l2tp" {
// 追加 xl2tpd 包(L2TP 协议必需)
for pm, cmd := range hints["strongswan"] { for pm, cmd := range hints["strongswan"] {
if pm == "apt-get" { switch pm {
case "apt-get":
hints["strongswan"][pm] = cmd + " xl2tpd" hints["strongswan"][pm] = cmd + " xl2tpd"
} else { case "yum", "dnf":
hints["strongswan"][pm] = strings.Replace(cmd, "strongswan\"", "strongswan xl2tpd\"", 1) hints["strongswan"][pm] = cmd + " && " + xl2tpdInstallCommand(pm)
if !strings.Contains(cmd, "xl2tpd") { default:
hints["strongswan"][pm] = cmd + " && " + map[string]string{ hints["strongswan"][pm] = cmd + " && " + map[string]string{
"yum": "yum install -y xl2tpd", "dnf": "dnf install -y xl2tpd", "apk": "apk add xl2tpd", "pacman": "pacman -S --noconfirm xl2tpd",
"apk": "apk add xl2tpd", "pacman": "pacman -S --noconfirm xl2tpd", }[pm]
}[pm]
}
} }
} }
} }
@@ -442,6 +440,11 @@ func InstallHint(protocol string) map[string]interface{} {
} }
} }
func xl2tpdInstallCommand(pm string) string {
pkg := pm
return fmt.Sprintf(`if %s list --available xl2tpd >/dev/null 2>&1; then %s install -y xl2tpd; else %s install -y gcc make libpcap-devel git && workdir=$(mktemp -d) && git clone --depth 1 https://github.com/xelerance/xl2tpd.git "$workdir/xl2tpd" && make -C "$workdir/xl2tpd" && install -m 0755 "$workdir/xl2tpd/xl2tpd" /usr/local/sbin/xl2tpd && rm -rf "$workdir"; fi`, pkg, pkg, pkg)
}
// ---- 一键包管理器安装 ---- // ---- 一键包管理器安装 ----
// InstallViaPackageManager 通过系统包管理器一键安装协议依赖。 // InstallViaPackageManager 通过系统包管理器一键安装协议依赖。
@@ -494,7 +497,7 @@ type BinaryStatus struct {
Name string `json:"name"` Name string `json:"name"`
Installed bool `json:"installed"` Installed bool `json:"installed"`
Path string `json:"path"` Path string `json:"path"`
Source string `json:"source"` // managed / system / undeployed Source string `json:"source"` // managed / system / undeployed
Version string `json:"version"` Version string `json:"version"`
CanDeploy bool `json:"can_deploy"` // 是否支持平台下载(仅 frp) CanDeploy bool `json:"can_deploy"` // 是否支持平台下载(仅 frp)
InstallCmd string `json:"install_cmd"` // 包管理器安装命令(非 frp) InstallCmd string `json:"install_cmd"` // 包管理器安装命令(非 frp)
+17
View File
@@ -0,0 +1,17 @@
package binmgr
import (
"strings"
"testing"
)
func TestInstallHintL2TPDnfFallback(t *testing.T) {
hint := InstallHint("l2tp")
commands, _ := hint["all_commands"].(map[string]string)
cmd := commands["dnf"]
for _, want := range []string{"dnf install -y strongswan", "xelerance/xl2tpd", "libpcap-devel"} {
if !strings.Contains(cmd, want) {
t.Fatalf("L2TP dnf install command missing %q: %s", want, cmd)
}
}
}
+113 -1
View File
@@ -44,6 +44,15 @@ func LookBin(names ...string) (string, bool) {
if p, err := exec.LookPath(n); err == nil { if p, err := exec.LookPath(n); err == nil {
return p, true return p, true
} }
// systemd services often omit /usr/local/{s,}bin from PATH. Locally
// compiled dependencies such as xl2tpd are conventionally installed
// there, so discover them without relying on the caller's environment.
for _, dir := range []string{"/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin"} {
p := filepath.Join(dir, n)
if info, err := os.Stat(p); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
return p, true
}
}
} }
return "", false return "", false
} }
@@ -78,12 +87,52 @@ func RequireIPv4Forwarding(component string) error {
return fmt.Errorf("无法启动 %s:未检测到 sysctl,无法确认 net.ipv4.ip_forward", component) return fmt.Errorf("无法启动 %s:未检测到 sysctl,无法确认 net.ipv4.ip_forward", component)
} }
out, err := Run(sysctl, "-n", "net.ipv4.ip_forward") out, err := Run(sysctl, "-n", "net.ipv4.ip_forward")
if err == nil && strings.TrimSpace(out) == "1" {
return nil
}
// A VPN service needs host forwarding to route traffic from tun/xfrm
// interfaces. Enable it at start instead of requiring a manual pre-step.
if _, err := Run(sysctl, "-w", "net.ipv4.ip_forward=1"); err != nil {
return fmt.Errorf("无法启动 %s:自动启用 net.ipv4.ip_forward 失败: %w", component, err)
}
out, err = Run(sysctl, "-n", "net.ipv4.ip_forward")
if err != nil || strings.TrimSpace(out) != "1" { if err != nil || strings.TrimSpace(out) != "1" {
return fmt.Errorf("无法启动 %s:请先执行 sysctl -w net.ipv4.ip_forward=1", component) return fmt.Errorf("无法启动 %s:net.ipv4.ip_forward 未成功启用", component)
} }
return nil return nil
} }
// EnsureStrongSwanVICI makes sure swanctl can reach charon's VICI socket.
// RPM-based distributions commonly install the binary without enabling the
// strongswan.service unit, which otherwise produces a misleading load error.
func EnsureStrongSwanVICI(swanctl string) error {
if _, err := Run(swanctl, "--list-conns"); err == nil {
return nil
}
systemctl, ok := LookBin("systemctl")
if !ok {
return fmt.Errorf("strongSwan 守护进程未运行,且未检测到 systemctl 以启动 charon")
}
var lastErr error
for _, unit := range []string{"strongswan.service", "strongswan-swanctl.service"} {
if _, err := Run(systemctl, "enable", "--now", unit); err != nil {
lastErr = err
continue
}
// Give systemd a short interval to create charon's VICI socket.
for range 10 {
if _, err := Run(swanctl, "--list-conns"); err == nil {
return nil
}
time.Sleep(100 * time.Millisecond)
}
}
if lastErr != nil {
return fmt.Errorf("无法启动 strongSwan 守护进程: %w", lastErr)
}
return fmt.Errorf("strongSwan 守护进程已启动但 VICI 接口不可用")
}
// RequireUDPPortAvailable checks a port before handing it to a daemon. This is // RequireUDPPortAvailable checks a port before handing it to a daemon. This is
// deliberately a real bind check instead of parsing process output, so it also // deliberately a real bind check instead of parsing process output, so it also
// catches listeners started outside this platform. // catches listeners started outside this platform.
@@ -230,6 +279,69 @@ func RemoveInstance(t *model.Tunnel) error {
return os.RemoveAll(InstanceDir(t)) return os.RemoveAll(InstanceDir(t))
} }
// SnapshotInstance captures one tunnel's generated files before a live
// configuration update. It intentionally excludes no files: protocol-specific
// keys, PID metadata and auxiliary scripts must be restored as one unit.
func SnapshotInstance(t *model.Tunnel) (string, error) {
source := InstanceDir(t)
parent := filepath.Dir(source)
if err := os.MkdirAll(parent, 0o700); err != nil {
return "", err
}
backup, err := os.MkdirTemp(parent, ".opsx-backup-")
if err != nil {
return "", err
}
if _, err := os.Stat(source); os.IsNotExist(err) {
return backup, nil
} else if err != nil {
_ = os.RemoveAll(backup)
return "", err
}
if err := copyTree(source, backup); err != nil {
_ = os.RemoveAll(backup)
return "", err
}
return backup, nil
}
// RestoreInstanceSnapshot replaces generated files with a previous snapshot.
func RestoreInstanceSnapshot(t *model.Tunnel, backup string) error {
if err := os.RemoveAll(InstanceDir(t)); err != nil {
return err
}
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
return err
}
return copyTree(backup, InstanceDir(t))
}
func CleanupInstanceSnapshot(backup string) { _ = os.RemoveAll(backup) }
func copyTree(source, destination string) error {
return filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
target := filepath.Join(destination, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode().Perm())
}
if !info.Mode().IsRegular() {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, content, info.Mode().Perm())
})
}
// Str / StrOr / Num / Bool 从配置 map 中安全取值 // Str / StrOr / Num / Bool 从配置 map 中安全取值
func Str(cfg map[string]interface{}, key string) string { func Str(cfg map[string]interface{}, key string) string {
if v, ok := cfg[key]; ok { if v, ok := cfg[key]; ok {
+39
View File
@@ -0,0 +1,39 @@
package service
import (
"os"
"path/filepath"
"testing"
"vpn-platform/backend/internal/model"
)
func TestInstanceSnapshotRestore(t *testing.T) {
base := t.TempDir()
t.Setenv("VPN_DATA_DIR", base)
tunnel := &model.Tunnel{ID: 42, Protocol: "openvpn"}
if _, err := WriteConfig(tunnel, "server.conf", "old-config"); err != nil {
t.Fatal(err)
}
backup, err := SnapshotInstance(tunnel)
if err != nil {
t.Fatal(err)
}
defer CleanupInstanceSnapshot(backup)
if _, err := WriteConfig(tunnel, "server.conf", "new-config"); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(InstanceDir(tunnel), "temporary"), []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
if err := RestoreInstanceSnapshot(tunnel, backup); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(InstanceDir(tunnel), "server.conf"))
if err != nil || string(got) != "old-config" {
t.Fatalf("restored config = %q, err=%v", got, err)
}
if _, err := os.Stat(filepath.Join(InstanceDir(tunnel), "temporary")); !os.IsNotExist(err) {
t.Fatalf("temporary file remained after restore: %v", err)
}
}
+29 -4
View File
@@ -5,6 +5,7 @@ package gre
import ( import (
"fmt" "fmt"
"net/netip" "net/netip"
"regexp"
"strings" "strings"
"vpn-platform/backend/internal/core" "vpn-platform/backend/internal/core"
@@ -15,6 +16,8 @@ import (
type Service struct{} type Service struct{}
var interfaceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,15}$`)
func New() *Service { return &Service{} } func New() *Service { return &Service{} }
func (s *Service) Name() string { return "gre" } func (s *Service) Name() string { return "gre" }
@@ -38,18 +41,34 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
out := service.CloneConfig(cfg) out := service.CloneConfig(cfg)
// Validate IPs // Validate IPs
if _, err := netip.ParseAddr(service.Str(out, "local_endpoint")); err != nil { localEndpoint, err := netip.ParseAddr(service.Str(out, "local_endpoint"))
if err != nil || !localEndpoint.Is4() {
return nil, fmt.Errorf("local_endpoint 必须是有效的 IP 地址") return nil, fmt.Errorf("local_endpoint 必须是有效的 IP 地址")
} }
if _, err := netip.ParseAddr(service.Str(out, "remote_endpoint")); err != nil { remoteEndpoint, err := netip.ParseAddr(service.Str(out, "remote_endpoint"))
if err != nil || !remoteEndpoint.Is4() {
return nil, fmt.Errorf("remote_endpoint 必须是有效的 IP 地址") return nil, fmt.Errorf("remote_endpoint 必须是有效的 IP 地址")
} }
if _, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip")); err != nil { localTunnel, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip"))
if err != nil || !localTunnel.Addr().Is4() {
return nil, fmt.Errorf("local_tunnel_ip 必须是 CIDR 格式(如 10.10.0.1/30)") return nil, fmt.Errorf("local_tunnel_ip 必须是 CIDR 格式(如 10.10.0.1/30)")
} }
if _, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip")); err != nil { remoteTunnel, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip"))
if err != nil || !remoteTunnel.Addr().Is4() {
return nil, fmt.Errorf("remote_tunnel_ip 必须是 CIDR 格式(如 10.10.0.2/30)") return nil, fmt.Errorf("remote_tunnel_ip 必须是 CIDR 格式(如 10.10.0.2/30)")
} }
if localTunnel == remoteTunnel || localTunnel.Masked() != remoteTunnel.Masked() || localTunnel.Bits() != remoteTunnel.Bits() {
return nil, fmt.Errorf("local_tunnel_ip 与 remote_tunnel_ip 必须位于同一隧道子网")
}
if remoteSubnet := service.Str(out, "remote_subnet"); remoteSubnet != "" {
prefix, err := netip.ParsePrefix(remoteSubnet)
if err != nil || !prefix.Addr().Is4() {
return nil, fmt.Errorf("remote_subnet 必须是 IPv4 CIDR")
}
}
if outbound := service.Str(out, "outbound_interface"); outbound != "" && !interfaceNamePattern.MatchString(outbound) {
return nil, fmt.Errorf("outbound_interface 包含非法字符")
}
// Defaults // Defaults
if service.Num(out, "ttl", 0) == 0 { if service.Num(out, "ttl", 0) == 0 {
@@ -58,6 +77,12 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
if service.Num(out, "mtu", 0) == 0 { if service.Num(out, "mtu", 0) == 0 {
out["mtu"] = 1476 out["mtu"] = 1476
} }
if ttl := int(service.Num(out, "ttl", 255)); ttl < 1 || ttl > 255 {
return nil, fmt.Errorf("ttl 必须在 1-255")
}
if mtu := int(service.Num(out, "mtu", 1476)); mtu < 576 || mtu > 9000 {
return nil, fmt.Errorf("mtu 必须在 576-9000")
}
return out, nil return out, nil
} }
+31
View File
@@ -0,0 +1,31 @@
package gre
import "testing"
func TestValidateGRE(t *testing.T) {
cfg, err := New().Validate(map[string]interface{}{
"local_endpoint": "198.51.100.10",
"remote_endpoint": "203.0.113.20",
"local_tunnel_ip": "10.255.0.1/30",
"remote_tunnel_ip": "10.255.0.2/30",
"remote_subnet": "192.168.10.0/24",
})
if err != nil {
t.Fatal(err)
}
if cfg["ttl"] != 255 || cfg["mtu"] != 1476 {
t.Fatalf("defaults not applied: %#v", cfg)
}
}
func TestValidateGRERejectsDifferentTunnelNetworks(t *testing.T) {
_, err := New().Validate(map[string]interface{}{
"local_endpoint": "198.51.100.10",
"remote_endpoint": "203.0.113.20",
"local_tunnel_ip": "10.255.0.1/30",
"remote_tunnel_ip": "10.255.1.2/30",
})
if err == nil {
t.Fatal("expected tunnel subnet validation error")
}
}
+9 -26
View File
@@ -57,9 +57,6 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key) 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 return out, nil
} }
@@ -78,31 +75,18 @@ func (s *Service) Create(t *model.Tunnel) error {
} }
secrets["psk"] = psk secrets["psk"] = psk
} }
if pw := service.Str(cfg, "password"); pw != "" {
secrets["password"] = pw
}
username := service.StrOr(cfg, "username", "vpnuser")
dialUsers, err := service.LoadDialUsers(t.ID) dialUsers, err := service.LoadDialUsers(t.ID)
if err != nil { if err != nil {
return err return err
} }
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 { conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), "", "", dialUsers)
// 自动生成一个默认拨号密码,避免空库无法联调。
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 { if _, err := service.WriteConfig(t, "swanctl-ikev2.conf", conf); err != nil {
return err return err
} }
if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets, username)); err != nil { if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets)); err != nil {
return err return err
} }
public, sec := service.MergeSecrets(cfg, "psk", "password") public, sec := service.MergeSecrets(cfg, "psk")
for k, v := range secrets { for k, v := range secrets {
sec[k] = v sec[k] = v
} }
@@ -188,7 +172,7 @@ pools {
) )
} }
func (s *Service) clientGuide(cfg, secrets map[string]interface{}, username string) string { func (s *Service) clientGuide(cfg, secrets map[string]interface{}) string {
return fmt.Sprintf(`======== IKEv2 远程接入参数 ======== return fmt.Sprintf(`======== IKEv2 远程接入参数 ========
服务器地址 : %s 服务器地址 : %s
@@ -198,8 +182,6 @@ VPN 类型 : IKEv2
机器认证 : 预共享密钥 (PSK) 机器认证 : 预共享密钥 (PSK)
预共享密钥 : %s 预共享密钥 : %s
用户认证 : 用户名 + 密码(EAP-MSCHAPv2) 用户认证 : 用户名 + 密码(EAP-MSCHAPv2)
默认用户名 : %s
默认密码 : %s
地址池 : %s 地址池 : %s
DNS : %s DNS : %s
@@ -208,15 +190,13 @@ DNS : %s
服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK 服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK
- Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2 - Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2
- Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP - Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP
- 更多账号 : 在平台「VPN 拨号用户」中为本隧道添加用户后重新生成配置 - 账号密码 : 在平台「VPN 拨号用户」中为本隧道创建后使用
注意:修改拨号用户后需在专线列表对该隧道执行「停止 → 启动」或重新保存配置以刷新 EAP 密钥。 注意:修改拨号用户后需在专线列表对该隧道执行「停止 → 启动」或重新保存配置以刷新 EAP 密钥。
`, `,
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP 或域名>"), service.StrOr(cfg, "public_endpoint", "<服务器公网 IP 或域名>"),
service.Str(cfg, "local_id"), service.Str(cfg, "local_id"),
service.Str(secrets, "psk"), service.Str(secrets, "psk"),
username,
service.Str(secrets, "password"),
service.Str(cfg, "address_pool"), service.Str(cfg, "address_pool"),
service.StrOr(cfg, "dns", service.DefaultDNS()), service.StrOr(cfg, "dns", service.DefaultDNS()),
) )
@@ -228,7 +208,7 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
return "", err return "", err
} }
secrets, _ := store.TunnelSecrets(t) secrets, _ := store.TunnelSecrets(t)
return s.clientGuide(cfg, secrets, service.StrOr(cfg, "username", "vpnuser")), nil return s.clientGuide(cfg, secrets), nil
} }
func (s *Service) Start(t *model.Tunnel) error { func (s *Service) Start(t *model.Tunnel) error {
@@ -240,6 +220,9 @@ func (s *Service) Start(t *model.Tunnel) error {
if err := service.RequireRoot("IKEv2"); err != nil { if err := service.RequireRoot("IKEv2"); err != nil {
return err return err
} }
if err := service.EnsureStrongSwanVICI(bin); err != nil {
return fmt.Errorf("无法启动 IKEv2:%w", err)
}
if err := service.RequireIPv4Forwarding("IKEv2"); err != nil { if err := service.RequireIPv4Forwarding("IKEv2"); err != nil {
return err return err
} }
+3
View File
@@ -327,6 +327,9 @@ func (s *Service) Start(t *model.Tunnel) error {
if err := service.RequireRoot("IPSec"); err != nil { if err := service.RequireRoot("IPSec"); err != nil {
return err return err
} }
if err := service.EnsureStrongSwanVICI(bin); err != nil {
return fmt.Errorf("无法启动 IPSec:%w", err)
}
if err := service.RequireIPv4Forwarding("IPSec"); err != nil { if err := service.RequireIPv4Forwarding("IPSec"); err != nil {
return err return err
} }
+18 -18
View File
@@ -2,6 +2,8 @@
package l2tp package l2tp
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net/netip" "net/netip"
"os" "os"
@@ -103,20 +105,17 @@ func (s *Service) Create(t *model.Tunnel) error {
if psk := service.Str(cfg, "psk"); psk != "" { if psk := service.Str(cfg, "psk"); psk != "" {
secrets["psk"] = psk secrets["psk"] = psk
} else if service.Str(secrets, "psk") == "" { } else if service.Str(secrets, "psk") == "" {
return fmt.Errorf("缺少 IPSec 预共享密钥 psk") // A per-tunnel key avoids accidentally sharing a weak UI default.
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("生成 IPSec 预共享密钥失败: %w", err)
}
secrets["psk"] = hex.EncodeToString(buf)
} }
username := service.StrOr(cfg, "username", "vpnuser")
if pw := service.Str(cfg, "password"); pw != "" {
secrets["password"] = pw
}
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
dialUsers, err := service.LoadDialUsers(t.ID) dialUsers, err := service.LoadDialUsers(t.ID)
if err != nil { if err != nil {
return err return err
} }
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
return fmt.Errorf("缺少拨号密码:请填写隧道默认 password,或在「VPN 拨号用户」中添加本隧道用户")
}
// 1) IPSec 层(transport 模式,L2TP over IPSec) // 1) IPSec 层(transport 模式,L2TP over IPSec)
swanConf := fmt.Sprintf(`connections { swanConf := fmt.Sprintf(`connections {
@@ -180,7 +179,7 @@ mru 1400
require-mschap-v2 require-mschap-v2
`, service.Str(cfg, "dns")) `, service.Str(cfg, "dns"))
chapSecrets := buildChapSecrets(username, service.Str(secrets, "password"), dialUsers) chapSecrets := buildChapSecrets("", "", dialUsers)
for name, content := range map[string]string{ for name, content := range map[string]string{
"swanctl-l2tp.conf": swanConf, "swanctl-l2tp.conf": swanConf,
@@ -192,7 +191,7 @@ require-mschap-v2
return err return err
} }
} }
public, sec := service.MergeSecrets(cfg, "psk", "password") public, sec := service.MergeSecrets(cfg, "psk")
for k, v := range secrets { for k, v := range secrets {
sec[k] = v sec[k] = v
} }
@@ -204,8 +203,6 @@ func (s *Service) RefreshAuth(t *model.Tunnel) error {
return s.Create(t) return s.Create(t)
} }
func buildChapSecrets(defaultUser, defaultPass string, users []service.DialUser) string { func buildChapSecrets(defaultUser, defaultPass string, users []service.DialUser) string {
var b strings.Builder var b strings.Builder
b.WriteString("# client server secret IP addresses\n") b.WriteString("# client server secret IP addresses\n")
@@ -236,19 +233,17 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
服务器地址 : %s 服务器地址 : %s
VPN 类型 : L2TP/IPSec PSK(预共享密钥) VPN 类型 : L2TP/IPSec PSK(预共享密钥)
IPSec PSK : %s IPSec PSK : %s
用户名 : %s
密码 : %s
客户端配置指引: 客户端配置指引:
- iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥 - iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥
- Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T - Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T
- Android : 部分新机型已移除 L2TP,建议改用 WireGuard - Android : 部分新机型已移除 L2TP,建议改用 WireGuard
- 路由器 : 选择 L2TP 拨号,填入上述参数 - 路由器 : 选择 L2TP 拨号,填入上述参数
账号密码请在「VPN 拨号用户」中创建或导入后使用。
`, `,
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"), service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"),
secrets["psk"], secrets["psk"],
service.StrOr(cfg, "username", "vpnuser"),
secrets["password"],
), nil ), nil
} }
@@ -262,6 +257,9 @@ func (s *Service) Start(t *model.Tunnel) error {
if err := service.RequireRoot("L2TP/IPSec"); err != nil { if err := service.RequireRoot("L2TP/IPSec"); err != nil {
return err return err
} }
if err := service.EnsureStrongSwanVICI(swanctl); err != nil {
return fmt.Errorf("无法启动 L2TP/IPSec:%w", err)
}
if err := service.RequireIPv4Forwarding("L2TP/IPSec"); err != nil { if err := service.RequireIPv4Forwarding("L2TP/IPSec"); err != nil {
return err return err
} }
@@ -286,7 +284,9 @@ func (s *Service) Start(t *model.Tunnel) error {
_ = s.removeFirewall(t, cfg) _ = s.removeFirewall(t, cfg)
return err return err
} }
if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-D"); err != nil { // Source-built xl2tpd defaults its control socket to /var/run/xl2tpd,
// which is absent on minimal TencentOS images. Keep it instance-scoped.
if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-C", dir+"/l2tp-control", "-D"); err != nil {
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf") _ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
_ = s.removeFirewall(t, cfg) _ = s.removeFirewall(t, cfg)
return err return err
+140 -38
View File
@@ -40,6 +40,7 @@ const (
otpPeriod = 30 otpPeriod = 30
otpDigits = 6 otpDigits = 6
otpDefaultSlop = 90 otpDefaultSlop = 90
pkiExpireDays = 99 * 365
) )
type Service struct{} type Service struct{}
@@ -75,7 +76,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
return nil, fmt.Errorf("port 必须是 1-65535 的整数") return nil, fmt.Errorf("port 必须是 1-65535 的整数")
} }
if service.Str(out, "proto") == "" { if service.Str(out, "proto") == "" {
out["proto"] = "udp" out["proto"] = "tcp-server"
} }
// 前端/API 可用 udp | tcp | tcp-server;服务端统一写成 udp 或 tcp-server。 // 前端/API 可用 udp | tcp | tcp-server;服务端统一写成 udp 或 tcp-server。
switch strings.ToLower(service.Str(out, "proto")) { switch strings.ToLower(service.Str(out, "proto")) {
@@ -105,7 +106,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
return nil, fmt.Errorf("dns 必须是 IP 地址") return nil, fmt.Errorf("dns 必须是 IP 地址")
} }
if service.Str(out, "auth_mode") == "" { if service.Str(out, "auth_mode") == "" {
out["auth_mode"] = "certificate" out["auth_mode"] = "password"
} }
switch service.Str(out, "auth_mode") { switch service.Str(out, "auth_mode") {
case "certificate", "password", "certificate+password": case "certificate", "password", "certificate+password":
@@ -151,25 +152,23 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
// 默认 password_is_cr=1:期望 static-challenge / SCRV1 响应格式。 // 默认 password_is_cr=1:期望 static-challenge / SCRV1 响应格式。
out["otp_password_is_cr"] = true out["otp_password_is_cr"] = true
} }
if service.Str(out, "push_routes") != "" { if normalized, err := normalizePushRoutes(service.Str(out, "push_routes")); err != nil {
for _, r := range strings.Split(service.Str(out, "push_routes"), ",") { return nil, err
r = strings.TrimSpace(r) } else {
if r == "" { out["push_routes"] = normalized
continue
}
if _, _, err := net.ParseCIDR(r); err != nil {
return nil, fmt.Errorf("push_routes 含非法 CIDR: %s", r)
}
}
} }
if _, ok := out["redirect_gateway"]; !ok { if _, ok := out["redirect_gateway"]; !ok {
// 未填 push_routes 时默认全流量;有 push_routes 时默认仅推内网。 // 未填有效路由时默认全流量;只有注释不应改变默认出口。
out["redirect_gateway"] = service.Str(out, "push_routes") == "" routes, _ := parsePushRoutes(service.Str(out, "push_routes"))
out["redirect_gateway"] = len(routes) == 0
} }
if _, ok := out["pki_auto_init"]; !ok { if _, ok := out["pki_auto_init"]; !ok {
out["pki_auto_init"] = true out["pki_auto_init"] = true
} }
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" { if _, ok := out["enable_nat"]; !ok {
out["enable_nat"] = true
}
if service.Bool(out, "enable_nat", true) && service.Str(out, "egress_interface") == "" {
if iface, err := service.DefaultRouteInterface(); err == nil { if iface, err := service.DefaultRouteInterface(); err == nil {
out["egress_interface"] = iface out["egress_interface"] = iface
} }
@@ -235,6 +234,66 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
return out, nil return out, nil
} }
func normalizePushRoutes(raw string) (string, error) {
lines := make([]string, 0)
for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
comment, routePart := "", trimmed
if idx := strings.Index(routePart, "#"); idx >= 0 {
comment, routePart = strings.TrimSpace(routePart[idx:]), strings.TrimSpace(routePart[:idx])
}
routes, err := parsePushRoutes(routePart)
if err != nil {
return "", err
}
if len(routes) == 0 && comment != "" {
lines = append(lines, comment)
continue
}
if len(routes) > 0 {
line = strings.Join(routes, ", ")
if comment != "" {
line += " " + comment
}
lines = append(lines, line)
}
}
return strings.Join(lines, "\n"), nil
}
func parsePushRoutes(raw string) ([]string, error) {
routes := make([]string, 0)
for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
if idx := strings.Index(line, "#"); idx >= 0 {
line = line[:idx]
}
for _, route := range strings.Split(line, ",") {
route = strings.TrimSpace(route)
if route == "" {
continue
}
if !strings.Contains(route, "/") {
ip := net.ParseIP(route).To4()
if ip == nil {
return nil, fmt.Errorf("push_routes 含非法 IP 或 CIDR: %s", route)
}
route = ip.String() + "/32"
}
ip, network, err := net.ParseCIDR(route)
if err != nil || ip.To4() == nil {
return nil, fmt.Errorf("push_routes 含非法 IPv4 CIDR: %s", route)
}
routes = append(routes, ip.Mask(network.Mask).String()+"/"+strconv.Itoa(maskSize(network.Mask)))
}
}
return routes, nil
}
func maskSize(mask net.IPMask) int { size, _ := mask.Size(); return size }
func parseSubnet(v string) (net.IP, net.IPMask, error) { func parseSubnet(v string) (net.IP, net.IPMask, error) {
f := strings.Fields(v) f := strings.Fields(v)
if len(f) != 2 { if len(f) != 2 {
@@ -296,17 +355,14 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
crl := filepath.Join(dir, "pki", "crl.pem") 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, "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")) fmt.Fprintf(&b, "topology subnet\nserver %s\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"))
// push:DNS / 全流量 / 内网路由 // push:DNS / 全流量 / 内网路由
fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", service.DefaultDNS())) fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", service.DefaultDNS()))
if service.Bool(cfg, "redirect_gateway", true) { if service.Bool(cfg, "redirect_gateway", true) {
b.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n") b.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
} }
for _, r := range strings.Split(service.Str(cfg, "push_routes"), ",") { routes, _ := parsePushRoutes(service.Str(cfg, "push_routes"))
r = strings.TrimSpace(r) for _, r := range routes {
if r == "" {
continue
}
ip, network, err := net.ParseCIDR(r) ip, network, err := net.ParseCIDR(r)
if err != nil { if err != nil {
continue continue
@@ -316,7 +372,7 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
} }
// 会话保活(生产 keepalive 5 30) // 会话保活(生产 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, "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")) fmt.Fprintf(&b, "data-ciphers %s:AES-128-GCM\ndata-ciphers-fallback %s\npersist-tun\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), service.StrOr(cfg, "cipher", "AES-256-GCM"))
// reneg-sec:生产/MFA 常 0,避免周期性 TLS 重协商 // reneg-sec:生产/MFA 常 0,避免周期性 TLS 重协商
fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0))) fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0)))
if service.Bool(cfg, "duplicate_cn", false) { if service.Bool(cfg, "duplicate_cn", false) {
@@ -372,6 +428,10 @@ func (s *Service) InitializePKI(t *model.Tunnel) error {
if err := os.MkdirAll(dir, 0o700); err != nil { if err := os.MkdirAll(dir, 0o700); err != nil {
return err return err
} }
// Any PKI that failed the readiness check cannot start a server safely.
if err := os.RemoveAll(filepath.Join(dir, "pki")); err != nil {
return fmt.Errorf("清理不完整的 OpenVPN PKI 失败: %w", err)
}
if err := runEasyRSA(dir, easy, "init-pki"); err != nil { if err := runEasyRSA(dir, easy, "init-pki"); err != nil {
return err return err
} }
@@ -403,9 +463,16 @@ func easyRSAPath() string {
if p, ok := service.LookBin("easyrsa"); ok { if p, ok := service.LookBin("easyrsa"); ok {
return p return p
} }
for _, p := range []string{"/usr/share/easy-rsa/easyrsa", "/usr/local/share/easy-rsa/easyrsa"} { for _, pattern := range []string{
if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() { "/usr/share/easy-rsa/easyrsa",
return p "/usr/share/easy-rsa/*/easyrsa",
"/usr/local/share/easy-rsa/easyrsa",
} {
paths, _ := filepath.Glob(pattern)
for _, p := range paths {
if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
return p
}
} }
} }
return "" return ""
@@ -413,7 +480,17 @@ func easyRSAPath() string {
func runEasyRSA(dir, binary string, args ...string) error { func runEasyRSA(dir, binary string, args ...string) error {
cmd := exec.Command(binary, args...) cmd := exec.Command(binary, args...)
cmd.Dir = dir cmd.Dir = dir
cmd.Env = append(os.Environ(), "EASYRSA_BATCH=1", "EASYRSA_REQ_CN=OPSX-VPN-CA") cmd.Env = append(os.Environ(),
"EASYRSA_BATCH=1",
fmt.Sprintf("EASYRSA_CA_EXPIRE=%d", pkiExpireDays),
fmt.Sprintf("EASYRSA_CERT_EXPIRE=%d", pkiExpireDays),
)
// Easy-RSA 3.1 rejects EASYRSA_REQ_CN for build-server/client-full because
// those commands derive the common name from their positional filename.
// It is only needed when the CA is created.
if len(args) >= 2 && args[len(args)-2] == "build-ca" {
cmd.Env = append(cmd.Env, "EASYRSA_REQ_CN=OPSX-VPN-CA")
}
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("easy-rsa %s 失败: %v: %s", strings.Join(args, " "), err, out) return fmt.Errorf("easy-rsa %s 失败: %v: %s", strings.Join(args, " "), err, out)
@@ -430,7 +507,8 @@ func pkiReady(t *model.Tunnel) error {
} }
func pkiScript(t *model.Tunnel) string { func pkiScript(t *model.Tunnel) string {
easy := shellQuote(easyRSAPathOrDefault()) easy := shellQuote(easyRSAPathOrDefault())
return "#!/bin/sh\nset -eu\ncd " + shellQuote(service.InstanceDir(t)) + "\nexport EASYRSA_BATCH=1 EASYRSA_REQ_CN=OPSX-VPN-CA\n" + easy + " init-pki\n" + easy + " --batch build-ca nopass\n" + easy + " --batch build-server-full server nopass\n" + easy + " gen-dh\n" + easy + " gen-crl\nopenvpn --genkey secret ta.key\necho 'OpenVPN PKI initialized'\n" expires := "EASYRSA_BATCH=1 EASYRSA_CA_EXPIRE=" + strconv.Itoa(pkiExpireDays) + " EASYRSA_CERT_EXPIRE=" + strconv.Itoa(pkiExpireDays)
return "#!/bin/sh\nset -eu\ncd " + shellQuote(service.InstanceDir(t)) + "\n" + easy + " init-pki\nEASYRSA_REQ_CN=OPSX-VPN-CA " + expires + " " + easy + " --batch build-ca nopass\n" + expires + " " + easy + " --batch build-server-full server nopass\n" + expires + " " + easy + " gen-dh\n" + expires + " " + easy + " gen-crl\nopenvpn --genkey secret ta.key\necho 'OpenVPN PKI initialized'\n"
} }
func easyRSAPathOrDefault() string { func easyRSAPathOrDefault() string {
if p := easyRSAPath(); p != "" { if p := easyRSAPath(); p != "" {
@@ -560,7 +638,8 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
} }
} }
var b strings.Builder var b strings.Builder
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")) cipher := service.StrOr(cfg, "cipher", "AES-256-GCM")
fmt.Fprintf(&b, "client\ndev tun\nproto %s\nremote %s %d\nresolv-retry infinite\nnobind\npersist-tun\nremote-cert-tls server\ndata-ciphers %s:AES-128-GCM\ndata-ciphers-fallback %s\nverb 3\n", clientProto(service.StrOr(cfg, "proto", "udp")), service.StrOr(cfg, "public_endpoint", "your-server-ip"), int(service.Num(cfg, "port", 1194)), cipher, cipher)
if mode == "password" || mode == "certificate+password" { if mode == "password" || mode == "certificate+password" {
b.WriteString("auth-user-pass\n") b.WriteString("auth-user-pass\n")
b.WriteString("auth-nocache\n") b.WriteString("auth-nocache\n")
@@ -596,6 +675,11 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
} }
username := service.Str(cfg, "client_username") username := service.Str(cfg, "client_username")
if username == "" { if username == "" {
// Password-only authentication has no per-user certificate. Generate a
// reusable profile so operators can distribute it before creating users.
if service.StrOr(cfg, "auth_mode", "certificate") == "password" {
return s.GenerateUserConfig(t, &model.VPNUser{})
}
return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置") return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置")
} }
var user model.VPNUser var user model.VPNUser
@@ -609,8 +693,17 @@ func (s *Service) Start(t *model.Tunnel) error {
if _, ok := findOpenVPNBin(); !ok { if _, ok := findOpenVPNBin(); !ok {
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn") return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
} }
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
if err := pkiReady(t); err != nil { if err := pkiReady(t); err != nil {
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err) if !service.Bool(cfg, "pki_auto_init", true) {
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
}
if initErr := s.InitializePKI(t); initErr != nil {
return fmt.Errorf("无法启动 OpenVPN:自动初始化 PKI 失败: %w", initErr)
}
} }
if err := service.RequireRoot("OpenVPN"); err != nil { if err := service.RequireRoot("OpenVPN"); err != nil {
return err return err
@@ -618,10 +711,6 @@ func (s *Service) Start(t *model.Tunnel) error {
if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil { if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil {
return err return err
} }
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
port := int(service.Num(cfg, "port", 1194)) port := int(service.Num(cfg, "port", 1194))
if service.StrOr(cfg, "proto", "udp") == "udp" { if service.StrOr(cfg, "proto", "udp") == "udp" {
if err := service.RequireUDPPortAvailable(port, "OpenVPN"); err != nil { if err := service.RequireUDPPortAvailable(port, "OpenVPN"); err != nil {
@@ -630,7 +719,7 @@ func (s *Service) Start(t *model.Tunnel) error {
} else if err := service.RequireTCPPortAvailable(port, "OpenVPN"); err != nil { } else if err := service.RequireTCPPortAvailable(port, "OpenVPN"); err != nil {
return err return err
} }
if service.Bool(cfg, "enable_nat", false) { if service.Bool(cfg, "enable_nat", true) {
if err := s.installNAT(t, cfg); err != nil { if err := s.installNAT(t, cfg); err != nil {
return err return err
} }
@@ -647,7 +736,7 @@ func (s *Service) Stop(t *model.Tunnel) error {
if err := service.KillPid(t); err != nil { if err := service.KillPid(t); err != nil {
firstErr = err firstErr = err
} }
if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", false) { if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", true) {
if err := s.removeNAT(t, cfg); err != nil && firstErr == nil { if err := s.removeNAT(t, cfg); err != nil && firstErr == nil {
firstErr = err firstErr = err
} }
@@ -677,8 +766,8 @@ func (s *Service) installNAT(t *model.Tunnel, cfg map[string]interface{}) error
comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID) comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID)
rules := [][]string{ rules := [][]string{
{"-t", "nat", "-A", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}, {"-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", "-i", "tun+", "-o", egress, "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
{"-A", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-A", "FORWARD", "-i", egress, "-o", "tun+", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
} }
for _, rule := range rules { for _, rule := range rules {
check := append([]string{}, rule...) check := append([]string{}, rule...)
@@ -718,6 +807,9 @@ func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error {
comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID) comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID)
candidates := [][]string{ candidates := [][]string{
{"-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}, {"-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"},
{"-D", "FORWARD", "-i", "tun+", "-o", egress, "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
{"-D", "FORWARD", "-i", egress, "-o", "tun+", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
// Rules produced by releases before interface-scoped forwarding.
{"-D", "FORWARD", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-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"}, {"-D", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
} }
@@ -775,7 +867,7 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
// 收集在线客户端信息(供 sessionmgr 同步会话) // 收集在线客户端信息(供 sessionmgr 同步会话)
var onlineClients []sessionmgr.ClientInfo var onlineClients []sessionmgr.ClientInfo
for _, line := range strings.Split(string(raw), "\n") { for _, line := range strings.Split(string(raw), "\n") {
f := strings.Split(line, ",") f := splitStatusFields(line)
if len(f) < 7 || f[0] != "CLIENT_LIST" { if len(f) < 7 || f[0] != "CLIENT_LIST" {
continue continue
} }
@@ -821,6 +913,16 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
sessionmgr.SyncSessions(t.ID, "openvpn", onlineClients) sessionmgr.SyncSessions(t.ID, "openvpn", onlineClients)
return totalRx, totalTx return totalRx, totalTx
} }
// splitStatusFields supports OpenVPN status-version 3 (tab-separated) and
// legacy comma-separated status files.
func splitStatusFields(line string) []string {
if strings.Contains(line, "\t") {
return strings.Split(line, "\t")
}
return strings.Split(line, ",")
}
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) } func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
// RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's // RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's
@@ -16,9 +16,12 @@ func TestValidateOpenVPNModes(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg["proto"] != "udp" || cfg["port"] != 1194 { if cfg["proto"] != "tcp-server" || cfg["port"] != 1194 || cfg["auth_mode"] != "password" {
t.Fatalf("defaults not applied: %#v", cfg) t.Fatalf("defaults not applied: %#v", cfg)
} }
if cfg["enable_nat"] != true {
t.Fatalf("NAT should default to enabled: %#v", cfg)
}
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil { if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
t.Fatal("expected invalid auth mode error") t.Fatal("expected invalid auth mode error")
} }
@@ -31,7 +34,7 @@ func TestValidateOpenVPNModes(t *testing.T) {
t.Fatalf("tcp not normalized: %#v", cfg2) t.Fatalf("tcp not normalized: %#v", cfg2)
} }
cfg3, err := New().Validate(map[string]interface{}{ cfg3, err := New().Validate(map[string]interface{}{
"subnet": "10.8.0.0 255.255.255.0", "push_routes": "192.168.1.0/24", "subnet": "10.8.0.0 255.255.255.0", "push_routes": "# VPC\n192.168.1.0/24\n# staging\n10.0.0.10",
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -39,6 +42,12 @@ func TestValidateOpenVPNModes(t *testing.T) {
if cfg3["redirect_gateway"] != false { if cfg3["redirect_gateway"] != false {
t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3) t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3)
} }
if cfg3["push_routes"] != "# VPC\n192.168.1.0/24\n# staging\n10.0.0.10/32" {
t.Fatalf("push_routes not normalized: %#v", cfg3)
}
if _, err := New().Validate(map[string]interface{}{"subnet": "10.8.0.0 255.255.255.0", "push_routes": "10.0.0.999"}); err == nil {
t.Fatal("expected invalid route to fail")
}
// MFA 仅允许密码相关模式 // MFA 仅允许密码相关模式
if _, err := New().Validate(map[string]interface{}{ if _, err := New().Validate(map[string]interface{}{
"auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0", "auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0",
@@ -102,6 +111,7 @@ func TestServerConfigIncludesProductionTunings(t *testing.T) {
"max-clients 1000", "max-clients 1000",
"duplicate-cn", "duplicate-cn",
"via-file", "via-file",
"data-ciphers-fallback AES-256-GCM",
} { } {
if !strings.Contains(raw, want) { if !strings.Contains(raw, want) {
t.Fatalf("server.conf missing %q\n%s", want, raw) t.Fatalf("server.conf missing %q\n%s", want, raw)
@@ -110,6 +120,11 @@ func TestServerConfigIncludesProductionTunings(t *testing.T) {
if strings.Contains(raw, "via-env") { if strings.Contains(raw, "via-env") {
t.Fatal("should use via-file not via-env") t.Fatal("should use via-file not via-env")
} }
for _, obsolete := range []string{"persist-key", "ifconfig-pool-persist"} {
if strings.Contains(raw, obsolete) {
t.Fatalf("server.conf should not contain obsolete %q\n%s", obsolete, raw)
}
}
} }
func TestValidateTOTPSkew(t *testing.T) { func TestValidateTOTPSkew(t *testing.T) {
@@ -134,3 +149,26 @@ func TestCertificateCNIsSafeAndStable(t *testing.T) {
t.Fatalf("unsafe CN: %q", cn) t.Fatalf("unsafe CN: %q", cn)
} }
} }
func TestPKIScriptUses99YearCertificates(t *testing.T) {
script := pkiScript(&model.Tunnel{ID: 1, Protocol: "openvpn"})
for _, want := range []string{"EASYRSA_CA_EXPIRE=36135", "EASYRSA_CERT_EXPIRE=36135"} {
if !strings.Contains(script, want) {
t.Fatalf("PKI script missing %q\n%s", want, script)
}
}
if !strings.Contains(script, "EASYRSA_REQ_CN=OPSX-VPN-CA") {
t.Fatalf("PKI script should set a CA name: %s", script)
}
if strings.Contains(script, "export EASYRSA_BATCH=1 EASYRSA_REQ_CN") {
t.Fatalf("PKI script must not export the CA CN to server/client certificate commands: %s", script)
}
}
func TestSplitStatusFieldsSupportsOpenVPNStatusV3(t *testing.T) {
line := "CLIENT_LIST\tadmin\ttcp4-server:203.0.113.1:1194\t10.8.0.2\t\t7161451\t8489433\t2026-07-25 23:32:58\t1784993578\tadmin"
fields := splitStatusFields(line)
if len(fields) != 10 || fields[5] != "7161451" || fields[9] != "admin" {
t.Fatalf("status-version 3 fields parsed incorrectly: %#v", fields)
}
}
+3 -3
View File
@@ -19,8 +19,8 @@ import (
"vpn-platform/backend/internal/monitor" "vpn-platform/backend/internal/monitor"
"vpn-platform/backend/internal/service" "vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/service/custom" "vpn-platform/backend/internal/service/custom"
"vpn-platform/backend/internal/service/gre"
"vpn-platform/backend/internal/service/frp" "vpn-platform/backend/internal/service/frp"
"vpn-platform/backend/internal/service/gre"
"vpn-platform/backend/internal/service/ikev2" "vpn-platform/backend/internal/service/ikev2"
"vpn-platform/backend/internal/service/ipsec" "vpn-platform/backend/internal/service/ipsec"
"vpn-platform/backend/internal/service/l2tp" "vpn-platform/backend/internal/service/l2tp"
@@ -114,9 +114,9 @@ func main() {
addr := os.Getenv("VPN_LISTEN_ADDR") addr := os.Getenv("VPN_LISTEN_ADDR")
if addr == "" { if addr == "" {
addr = ":8080" addr = ":18080"
} }
log.Printf("VPN 管理平台已启动: http://localhost%s (默认账号 admin / admin123)", addr) log.Printf("VPN 管理平台已启动: http://localhost%s", addr)
log.Printf("协议依赖检测:") log.Printf("协议依赖检测:")
for _, p := range mgr.List() { for _, p := range mgr.List() {
ok, dep := p.Available() ok, dep := p.Available()
+4
View File
@@ -33,6 +33,7 @@ const NAMES = computed(() => ({
ipsec: 'IPSec', ipsec: 'IPSec',
ikev2: 'IKEv2', ikev2: 'IKEv2',
l2tp: 'L2TP', l2tp: 'L2TP',
gre: 'GRE',
frp: 'FRP', frp: 'FRP',
openvpn: 'OpenVPN', openvpn: 'OpenVPN',
custom: t('protocolIcon.custom'), custom: t('protocolIcon.custom'),
@@ -62,6 +63,9 @@ const ICON_PATHS = {
// L2TP — 手机+信号波(移动设备拨号语义) // L2TP — 手机+信号波(移动设备拨号语义)
l2tp: 'M7 1C5.9 1 5 1.9 5 3v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2H7zm5 19c-.83 0-1.5-.67-1.5-1.5S11.17 17 12 17s1.5.67 1.5 1.5S12.83 20 12 20zm5-4H7V4h10v12z M9.5 6.5l2 2-2 2-1-1 1-1-1-1 1-1z M14.5 6.5l1 1-1 1 1 1-1 1-2-2 2-2z', l2tp: 'M7 1C5.9 1 5 1.9 5 3v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2H7zm5 19c-.83 0-1.5-.67-1.5-1.5S11.17 17 12 17s1.5.67 1.5 1.5S12.83 20 12 20zm5-4H7V4h10v12z M9.5 6.5l2 2-2 2-1-1 1-1-1-1 1-1z M14.5 6.5l1 1-1 1 1 1-1 1-2-2 2-2z',
// GRE — two routed sites connected by a tunnel
gre: 'M3 4h6v3H6v10h3v3H3V4zm12 0h6v16h-6v-3h3V7h-3V4zM9 10h6v2H9v-2zm0 4h6v2H9v-2z',
// FRP — 隧道+箭头穿透(内网穿透语义) // FRP — 隧道+箭头穿透(内网穿透语义)
frp: 'M3 8h2v8H3V8zm16 0h2v8h-2V8zM7 10h10v4H7v-4zm2 1.5l2 1.5-2 1.5v-3zm6 0v3l-2-1.5 2-1.5z M12 6l3 2H9l3-2zm0 12l-3-2h6l-3 2z', frp: 'M3 8h2v8H3V8zm16 0h2v8h-2V8zM7 10h10v4H7v-4zm2 1.5l2 1.5-2 1.5v-3zm6 0v3l-2-1.5 2-1.5z M12 6l3 2H9l3-2zm0 12l-3-2h6l-3 2z',
@@ -0,0 +1,44 @@
<template>
<div class="route-list">
<el-input v-model="content" type="textarea" :rows="7" resize="vertical"
:placeholder="t('protocols.openvpn.fields.push_routes.placeholder')" @blur="validate" />
<span v-if="error" class="route-error">{{ error }}</span>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({ modelValue: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const content = ref(props.modelValue || '')
const error = ref('')
watch(() => props.modelValue, value => {
if (value !== content.value) content.value = value || ''
})
watch(content, value => emit('update:modelValue', value))
function validRoute(value) {
const [ip, prefix] = value.split('/')
const parts = ip.split('.')
if (parts.length !== 4 || parts.some(part => !/^\d+$/.test(part) || Number(part) > 255)) return false
return prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) >= 0 && Number(prefix) <= 32)
}
function validate() {
const filled = String(content.value).split('\n').flatMap(line => line.split('#', 1)[0].split(',')).map(item => item.trim()).filter(Boolean)
const invalid = filled.find(item => !validRoute(item))
error.value = invalid ? t('protocols.openvpn.fields.push_routes.format_error', { route: invalid }) : ''
if (!invalid && content.value !== content.value.trim()) {
content.value = content.value.trim()
}
return !invalid
}
defineExpose({ validate })
</script>
<style scoped>
.route-list { display:grid; gap:8px; width:100%; }
.route-error { color:var(--el-color-danger); font-size:12px; }
</style>
+69 -22
View File
@@ -23,7 +23,7 @@
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
"username": "Username", "username": "Username",
"password": "***", "password": "Password",
"email": "Email", "email": "Email",
"source": "Source", "source": "Source",
"local": "Local", "local": "Local",
@@ -57,9 +57,9 @@
"login": { "login": {
"title": "VPN Management Platform", "title": "VPN Management Platform",
"subtitle": "Unified multi-protocol VPN management — simple, secure, and controllable", "subtitle": "Unified multi-protocol VPN management — simple, secure, and controllable",
"protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · Custom Protocol Plugins", "protocols": "WireGuard · IPSec · IKEv2 · L2TP · GRE · FRP · OpenVPN",
"feature1Title": "Multi-Protocol Unified Management", "feature1Title": "Multi-Protocol Unified Management",
"feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN", "feature1Desc": "WireGuard / IPSec / IKEv2 / L2TP / GRE / OpenVPN",
"feature2Title": "Enterprise-Grade Security", "feature2Title": "Enterprise-Grade Security",
"feature2Desc": "AES-GCM encrypted keys + Two-Factor Authentication", "feature2Desc": "AES-GCM encrypted keys + Two-Factor Authentication",
"feature3Title": "One-Click Configuration", "feature3Title": "One-Click Configuration",
@@ -89,6 +89,10 @@
"totalTraffic": "Total Traffic ↓↑", "totalTraffic": "Total Traffic ↓↑",
"protocolDistribution": "Protocol Distribution", "protocolDistribution": "Protocol Distribution",
"protocolEngineStatus": "Protocol Engine Status", "protocolEngineStatus": "Protocol Engine Status",
"hostReadinessWarning": "Host prerequisites are incomplete. VPN forwarding or NAT may not work correctly.",
"readinessRoot": "Service is not running as root",
"readinessForwarding": "IPv4 forwarding is disabled",
"readinessRoute": "No default egress route detected",
"depCheckResult": "System dependency check results", "depCheckResult": "System dependency check results",
"depReady": "Ready", "depReady": "Ready",
"demoMode": "Demo Mode", "demoMode": "Demo Mode",
@@ -217,6 +221,15 @@
"sensitiveFieldHint": "Sensitive fields like passwords, PSK, and tokens are not displayed. Leave empty to keep the current value.", "sensitiveFieldHint": "Sensitive fields like passwords, PSK, and tokens are not displayed. Leave empty to keep the current value.",
"tunnelName": "Tunnel Name", "tunnelName": "Tunnel Name",
"protocolType": "Protocol Type", "protocolType": "Protocol Type",
"coreOptions": "Core Connection Settings",
"advancedOptions": "Detailed Settings",
"trafficMode": "Traffic Egress",
"trafficGateway": "All Traffic via VPN Gateway",
"trafficStatic": "Static Routes Only",
"trafficCustom": "Custom",
"trafficGatewayTip": "Routes all client traffic through the VPN gateway with NAT egress.",
"trafficStaticTip": "Pushes only the target networks below and preserves the client's default Internet route.",
"trafficCustomTip": "Control the default route, static routes, and NAT separately in detailed settings.",
"keepOriginal": "Leave empty to keep original", "keepOriginal": "Leave empty to keep original",
"expireTime": "Expiry Time", "expireTime": "Expiry Time",
"expirePlaceholder": "Leave empty for permanent", "expirePlaceholder": "Leave empty for permanent",
@@ -241,6 +254,14 @@
"namePlaceholder": "e.g., HQ-Branch Interconnect", "namePlaceholder": "e.g., HQ-Branch Interconnect",
"protocolType": "Protocol Type", "protocolType": "Protocol Type",
"reselect": "Reselect", "reselect": "Reselect",
"coreOptions": "Core Connection Settings",
"trafficMode": "Traffic Egress",
"trafficGateway": "All Traffic via VPN Gateway",
"trafficStatic": "Static Routes Only",
"trafficCustom": "Custom",
"trafficGatewayTip": "Routes all client traffic through the VPN gateway with NAT egress.",
"trafficStaticTip": "Pushes only the target networks below and preserves the client's default Internet route.",
"trafficCustomTip": "Control the default route, static routes, and NAT separately in detailed settings.",
"advancedOptions": "Advanced Options", "advancedOptions": "Advanced Options",
"expireTime": "Expiry Time", "expireTime": "Expiry Time",
"expirePlaceholder": "Leave empty for permanent", "expirePlaceholder": "Leave empty for permanent",
@@ -337,6 +358,20 @@
"title": "VPN Dial-in Users", "title": "VPN Dial-in Users",
"mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)", "mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)",
"addUser": "Add User", "addUser": "Add User",
"bulkImport": "Bulk Import",
"bulkImportTitle": "Import Platform Users",
"bulkImportHint": "Only OpenVPN supports platform local or LDAP account imports. Create dedicated accounts for L2TP/IKEv2.",
"importSelected": "Import Selected",
"importedCount": "Imported {count} users",
"importFailed": "Bulk import failed",
"selectOpenVPNTunnel": "Select OpenVPN Tunnel",
"selectTunnelAndUsers": "Select an OpenVPN tunnel and platform users",
"searchPlaceholder": "Search username, name, or email",
"allTunnels": "All Tunnels",
"selectedUsers": "{count} users selected",
"batchDelete": "Bulk Delete",
"batchDeleteConfirm": "Delete the selected {count} VPN dial-in users?",
"batchDeleteDone": "Selected VPN dial-in users deleted",
"displayName": "Name", "displayName": "Name",
"tunnel": "Tunnel", "tunnel": "Tunnel",
"otpMfa": "OTP / MFA", "otpMfa": "OTP / MFA",
@@ -359,6 +394,14 @@
"clickRowHint": "Click a row to select and auto-fill", "clickRowHint": "Click a row to select and auto-fill",
"otpDialogTitle": "Two-Factor Auth — {username}", "otpDialogTitle": "Two-Factor Auth — {username}",
"otpHint": "TOTP-based two-factor authentication (SHA1 / base32 / 30s / 6 digits), compatible with major Authenticator apps. Supports static challenge and PIN+OTP combo mode.", "otpHint": "TOTP-based two-factor authentication (SHA1 / base32 / 30s / 6 digits), compatible with major Authenticator apps. Supports static challenge and PIN+OTP combo mode.",
"mfaNotEnabled": "Two-factor authentication is not enabled",
"mfaDisabledHint": "Bind an Authenticator app to require a one-time code when this user signs in to VPN.",
"mfaEnabledHint": "This user must provide both their password and a one-time code to sign in to VPN.",
"mfaSetupIntro": "Bind an Authenticator app to protect this user's VPN dial-in access.",
"manualSetup": "Can't scan? Use a manual key",
"verifySetup": "Confirm Binding",
"verifySetupHint": "After scanning in Authenticator, enter the current 6-digit code to verify the binding.",
"securityDetails": "Security Details",
"bindStatus": "Bind Status", "bindStatus": "Bind Status",
"boundTotp": "TOTP Bound", "boundTotp": "TOTP Bound",
"staticPin": "Static PIN", "staticPin": "Static PIN",
@@ -766,13 +809,6 @@
"label": "Machine Auth PSK", "label": "Machine Auth PSK",
"tip": "Leave empty to auto-generate; shared key for iOS/Android" "tip": "Leave empty to auto-generate; shared key for iOS/Android"
}, },
"username": {
"label": "Default Dial-in User"
},
"password": {
"label": "Default Dial-in Password",
"tip": "Leave empty to auto-generate; add more users in VPN Dial-in Users"
},
"address_pool": { "address_pool": {
"label": "Client Address Pool" "label": "Client Address Pool"
}, },
@@ -800,15 +836,8 @@
"label": "Server Public IP" "label": "Server Public IP"
}, },
"psk": { "psk": {
"label": "IPSec Pre-Shared Key" "label": "IPSec Pre-Shared Key",
}, "tip": "Leave blank to generate a high-strength random key; retrieve it securely from client configuration after creation"
"username": {
"label": "Default Dial-in Username",
"tip": "Add more users for this tunnel in VPN Dial-in Users"
},
"password": {
"label": "Default Dial-in Password",
"tip": "Leave empty if using only the dial-in user table (at least one method required at creation)"
}, },
"ip_range": { "ip_range": {
"label": "Client Address Pool" "label": "Client Address Pool"
@@ -821,6 +850,22 @@
} }
} }
}, },
"gre": {
"name": "GRE Tunnel",
"desc": "Standard IP protocol 47 tunnel for point-to-point Layer 3 connectivity between Linux, routers, and firewalls",
"scenes": "Network Device Interconnect / Site-to-Site Routing",
"fields": {
"local_endpoint": { "label": "Local Public IP" },
"remote_endpoint": { "label": "Remote Public IP" },
"local_tunnel_ip": { "label": "Local Tunnel IP", "tip": "Use a /30, for example 10.255.0.1/30" },
"remote_tunnel_ip": { "label": "Remote Tunnel IP", "tip": "Must share the local tunnel IP subnet" },
"remote_subnet": { "label": "Remote LAN Subnet", "tip": "Adds a static route through the GRE peer" },
"enable_nat": { "label": "NAT Remote Traffic", "tip": "Enable only when the remote subnet needs Internet egress via this host" },
"outbound_interface": { "label": "NAT Egress Interface" },
"ttl": { "label": "TTL" },
"mtu": { "label": "GRE MTU", "tip": "1476 is typical over public Internet GRE; lower it for additional encapsulation" }
}
},
"frp": { "frp": {
"name": "FRP NAT Traversal", "name": "FRP NAT Traversal",
"desc": "TCP/UDP/HTTP port forwarding and reverse proxy for NAT traversal", "desc": "TCP/UDP/HTTP port forwarding and reverse proxy for NAT traversal",
@@ -941,7 +986,7 @@
"tip": "Password mode aligns with verify-client-cert none + username-as-common-name", "tip": "Password mode aligns with verify-client-cert none + username-as-common-name",
"options": { "options": {
"certificate": "Certificate Only", "certificate": "Certificate Only",
"password": "***", "password": "Username + Password",
"certificate+password": "Certificate + Password" "certificate+password": "Certificate + Password"
} }
}, },
@@ -974,8 +1019,10 @@
"tip": "When off, only pushes internal routes below (common in production)" "tip": "When off, only pushes internal routes below (common in production)"
}, },
"push_routes": { "push_routes": {
"label": "Push Internal Routes", "label": "Push Routes",
"tip": "CIDR, comma-separated" "tip": "One IP or CIDR per line; # comments are supported",
"placeholder": "# VPC networks\n172.16.0.0/16\n# Staging\n43.129.24.233",
"format_error": "Invalid route format: {route}"
}, },
"enable_nat": { "enable_nat": {
"label": "Enable NAT Outbound" "label": "Enable NAT Outbound"
+69 -22
View File
@@ -23,7 +23,7 @@
"name": "名称", "name": "名称",
"description": "说明", "description": "说明",
"username": "用户名", "username": "用户名",
"password": "***", "password": "密码",
"email": "邮箱", "email": "邮箱",
"source": "来源", "source": "来源",
"local": "本地", "local": "本地",
@@ -57,9 +57,9 @@
"login": { "login": {
"title": "VPN 专线管理平台", "title": "VPN 专线管理平台",
"subtitle": "统一管理多协议 VPN,配置简单、安全可控", "subtitle": "统一管理多协议 VPN,配置简单、安全可控",
"protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件", "protocols": "WireGuard · IPSec · IKEv2 · L2TP · GRE · FRP · OpenVPN",
"feature1Title": "多协议统一管理", "feature1Title": "多协议统一管理",
"feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN", "feature1Desc": "WireGuard / IPSec / IKEv2 / L2TP / GRE / OpenVPN",
"feature2Title": "企业级安全", "feature2Title": "企业级安全",
"feature2Desc": "密钥 AES-GCM 加密 + 双因素认证", "feature2Desc": "密钥 AES-GCM 加密 + 双因素认证",
"feature3Title": "一键创建配置", "feature3Title": "一键创建配置",
@@ -89,6 +89,10 @@
"totalTraffic": "累计流量 ↓↑", "totalTraffic": "累计流量 ↓↑",
"protocolDistribution": "协议分布", "protocolDistribution": "协议分布",
"protocolEngineStatus": "协议引擎状态", "protocolEngineStatus": "协议引擎状态",
"hostReadinessWarning": "主机运行前提未完全满足,VPN 转发或 NAT 可能无法正常工作",
"readinessRoot": "服务未以 root 运行",
"readinessForwarding": "IPv4 转发未启用",
"readinessRoute": "未检测到默认出口路由",
"depCheckResult": "系统依赖检测结果", "depCheckResult": "系统依赖检测结果",
"depReady": "已就绪", "depReady": "已就绪",
"demoMode": "演示模式", "demoMode": "演示模式",
@@ -217,6 +221,15 @@
"sensitiveFieldHint": "密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。", "sensitiveFieldHint": "密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。",
"tunnelName": "专线名称", "tunnelName": "专线名称",
"protocolType": "协议类型", "protocolType": "协议类型",
"coreOptions": "核心接入配置",
"advancedOptions": "详细配置",
"trafficMode": "流量出口",
"trafficGateway": "全流量经 VPN 网关",
"trafficStatic": "仅静态路由",
"trafficCustom": "自定义",
"trafficGatewayTip": "客户端全部流量经 VPN 网关转发,并使用 NAT 出网。",
"trafficStaticTip": "仅向客户端推送下方填写的目标网段,不改变其默认互联网出口。",
"trafficCustomTip": "在详细配置中分别控制默认路由、静态路由与 NAT。",
"keepOriginal": "留空保持原值", "keepOriginal": "留空保持原值",
"expireTime": "到期时间", "expireTime": "到期时间",
"expirePlaceholder": "留空为永久", "expirePlaceholder": "留空为永久",
@@ -241,6 +254,14 @@
"namePlaceholder": "例如:总部-分支互联", "namePlaceholder": "例如:总部-分支互联",
"protocolType": "协议类型", "protocolType": "协议类型",
"reselect": "重新选择", "reselect": "重新选择",
"coreOptions": "核心接入配置",
"trafficMode": "流量出口",
"trafficGateway": "全流量经 VPN 网关",
"trafficStatic": "仅静态路由",
"trafficCustom": "自定义",
"trafficGatewayTip": "客户端全部流量经 VPN 网关转发,并使用 NAT 出网。",
"trafficStaticTip": "仅向客户端推送下方填写的目标网段,不改变其默认互联网出口。",
"trafficCustomTip": "在详细配置中分别控制默认路由、静态路由与 NAT。",
"advancedOptions": "高级选项", "advancedOptions": "高级选项",
"expireTime": "到期时间", "expireTime": "到期时间",
"expirePlaceholder": "留空为永久", "expirePlaceholder": "留空为永久",
@@ -337,6 +358,20 @@
"title": "VPN 拨号用户", "title": "VPN 拨号用户",
"mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)", "mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)",
"addUser": "添加用户", "addUser": "添加用户",
"bulkImport": "批量导入",
"bulkImportTitle": "批量导入平台用户",
"bulkImportHint": "仅 OpenVPN 支持导入平台本地或 LDAP 用户;L2TP/IKEv2 请创建独立拨号账号。",
"importSelected": "导入选中用户",
"importedCount": "已导入 {count} 个用户",
"importFailed": "批量导入失败",
"selectOpenVPNTunnel": "选择 OpenVPN 隧道",
"selectTunnelAndUsers": "请选择 OpenVPN 隧道和平台用户",
"searchPlaceholder": "搜索用户名、姓名或邮箱",
"allTunnels": "全部隧道",
"selectedUsers": "已选择 {count} 个用户",
"batchDelete": "批量删除",
"batchDeleteConfirm": "确认删除选中的 {count} 个拨号用户?",
"batchDeleteDone": "已删除选中拨号用户",
"displayName": "姓名", "displayName": "姓名",
"tunnel": "隧道", "tunnel": "隧道",
"otpMfa": "OTP / MFA", "otpMfa": "OTP / MFA",
@@ -359,6 +394,14 @@
"clickRowHint": "点击行即选中并自动填充", "clickRowHint": "点击行即选中并自动填充",
"otpDialogTitle": "双因素认证 — {username}", "otpDialogTitle": "双因素认证 — {username}",
"otpHint": "基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。", "otpHint": "基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。",
"mfaNotEnabled": "尚未启用双因素认证",
"mfaDisabledHint": "绑定验证器后,此用户登录 VPN 时需要额外输入动态验证码。",
"mfaEnabledHint": "该用户登录 VPN 时需要密码和动态验证码。",
"mfaSetupIntro": "为该用户绑定一个 Authenticator 应用,保护 VPN 拨号登录。",
"manualSetup": "无法扫码?使用手动密钥",
"verifySetup": "确认绑定",
"verifySetupHint": "在 Authenticator 中扫描后,输入当前显示的 6 位验证码完成验证。",
"securityDetails": "安全详情",
"bindStatus": "绑定状态", "bindStatus": "绑定状态",
"boundTotp": "已绑定 TOTP", "boundTotp": "已绑定 TOTP",
"staticPin": "静态 PIN", "staticPin": "静态 PIN",
@@ -766,13 +809,6 @@
"label": "机器认证 PSK", "label": "机器认证 PSK",
"tip": "留空自动生成;iOS/Android 共享密钥" "tip": "留空自动生成;iOS/Android 共享密钥"
}, },
"username": {
"label": "默认拨号用户"
},
"password": {
"label": "默认拨号密码",
"tip": "留空自动生成;更多账号在拨号用户页添加"
},
"address_pool": { "address_pool": {
"label": "客户端地址池" "label": "客户端地址池"
}, },
@@ -800,15 +836,8 @@
"label": "服务器公网 IP" "label": "服务器公网 IP"
}, },
"psk": { "psk": {
"label": "IPSec 预共享密钥" "label": "IPSec 预共享密钥",
}, "tip": "留空将自动生成高强度随机密钥;请在创建后从客户端配置中安全获取"
"username": {
"label": "默认拨号用户名",
"tip": "可在「VPN 拨号用户」为本隧道添加更多账号"
},
"password": {
"label": "默认拨号密码",
"tip": "若只使用拨号用户表可留空(创建时需至少有一种)"
}, },
"ip_range": { "ip_range": {
"label": "客户端地址池" "label": "客户端地址池"
@@ -821,6 +850,22 @@
} }
} }
}, },
"gre": {
"name": "GRE 隧道",
"desc": "标准 IP 协议 47 隧道,用于 Linux 与路由器、防火墙的点对点三层互联",
"scenes": "网络设备互联 / 跨站点路由",
"fields": {
"local_endpoint": { "label": "本端公网 IP" },
"remote_endpoint": { "label": "对端公网 IP" },
"local_tunnel_ip": { "label": "本端隧道 IP", "tip": "建议使用 /30,例如 10.255.0.1/30" },
"remote_tunnel_ip": { "label": "对端隧道 IP", "tip": "需与本端隧道 IP 位于同一子网" },
"remote_subnet": { "label": "对端业务网段", "tip": "填写后自动添加经 GRE 对端的静态路由" },
"enable_nat": { "label": "对端流量 NAT 出网", "tip": "仅当对端业务网段需要经本机访问互联网时启用" },
"outbound_interface": { "label": "NAT 出站网卡" },
"ttl": { "label": "TTL" },
"mtu": { "label": "GRE MTU", "tip": "公网 GRE 常用 1476;链路有额外封装时可继续降低" }
}
},
"frp": { "frp": {
"name": "FRP 内网穿透", "name": "FRP 内网穿透",
"desc": "国内刚需,TCP/UDP/HTTP 端口映射与反向代理", "desc": "国内刚需,TCP/UDP/HTTP 端口映射与反向代理",
@@ -941,7 +986,7 @@
"tip": "密码模式对齐生产 verify-client-cert none + username-as-common-name", "tip": "密码模式对齐生产 verify-client-cert none + username-as-common-name",
"options": { "options": {
"certificate": "仅证书", "certificate": "仅证书",
"password": "***", "password": "用户名 + 密码",
"certificate+password": "证书 + 密码" "certificate+password": "证书 + 密码"
} }
}, },
@@ -974,8 +1019,10 @@
"tip": "关闭后仅推送下方内网路由(生产常只推指定路由)" "tip": "关闭后仅推送下方内网路由(生产常只推指定路由)"
}, },
"push_routes": { "push_routes": {
"label": "推送内网路由", "label": "推送路由",
"tip": "CIDR 逗号分隔" "tip": "IP 或 CIDR,每行一个;支持 # 注释",
"placeholder": "# VPC 网段\n172.16.0.0/16\n# 预发环境\n43.129.24.233",
"format_error": "路由格式无效:{route}"
}, },
"enable_nat": { "enable_nat": {
"label": "启用 NAT 出网" "label": "启用 NAT 出网"
+77 -62
View File
@@ -9,16 +9,16 @@ export const PROTOCOL_META = {
icon: 'wireguard', icon: 'wireguard',
scenes: 'protocols.wireguard.scenes', scenes: 'protocols.wireguard.scenes',
fields: [ fields: [
{ key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip' }, { key: 'address', label: 'protocols.wireguard.fields.address.label', placeholder: '10.8.0.1/24', required: true, tip: 'protocols.wireguard.fields.address.tip', group: 'basic' },
{ key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820 }, { key: 'listen_port', label: 'protocols.wireguard.fields.listen_port.label', type: 'number', default: 51820, group: 'basic' },
{ key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip' }, { key: 'client_address', label: 'protocols.wireguard.fields.client_address.label', placeholder: '10.8.0.2/32', tip: 'protocols.wireguard.fields.client_address.tip', group: 'basic' },
{ key: 'public_endpoint', label: 'protocols.wireguard.fields.public_endpoint.label', placeholder: 'vpn.example.com:51820', tip: 'protocols.wireguard.fields.public_endpoint.tip' }, { key: 'public_endpoint', label: 'protocols.wireguard.fields.public_endpoint.label', placeholder: 'vpn.example.com:51820', tip: 'protocols.wireguard.fields.public_endpoint.tip', group: 'basic' },
{ key: 'dns', label: 'protocols.wireguard.fields.dns.label', default: '223.5.5.5' }, { key: 'dns', label: 'protocols.wireguard.fields.dns.label', default: '223.5.5.5', group: 'basic' },
{ key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip' }, { key: 'allowed_ips', label: 'protocols.wireguard.fields.allowed_ips.label', default: '0.0.0.0/0, ::/0', tip: 'protocols.wireguard.fields.allowed_ips.tip', group: 'routing' },
{ key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip' }, { key: 'keepalive', label: 'protocols.wireguard.fields.keepalive.label', type: 'number', default: 25, tip: 'protocols.wireguard.fields.keepalive.tip', group: 'advanced' },
{ key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip' }, { key: 'mtu', label: 'protocols.wireguard.fields.mtu.label', type: 'number', default: 0, tip: 'protocols.wireguard.fields.mtu.tip', group: 'advanced' },
{ key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip' }, { key: 'enable_nat', label: 'protocols.wireguard.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.wireguard.fields.enable_nat.tip', group: 'routing' },
{ key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true } } { key: 'egress_interface', label: 'protocols.wireguard.fields.egress_interface.label', placeholder: 'eth0', tip: 'protocols.wireguard.fields.egress_interface.tip', showIf: { enable_nat: true }, group: 'routing' }
] ]
}, },
ipsec: { ipsec: {
@@ -34,22 +34,22 @@ export const PROTOCOL_META = {
{ value: 'fortigate', label: 'protocols.ipsec.fields.vendor_template.options.fortigate' }, { value: 'fortigate', label: 'protocols.ipsec.fields.vendor_template.options.fortigate' },
{ value: 'huawei', label: 'protocols.ipsec.fields.vendor_template.options.huawei' }, { value: 'huawei', label: 'protocols.ipsec.fields.vendor_template.options.huawei' },
{ value: 'h3c', label: 'protocols.ipsec.fields.vendor_template.options.h3c' } { value: 'h3c', label: 'protocols.ipsec.fields.vendor_template.options.h3c' }
], default: 'generic', tip: 'protocols.ipsec.fields.vendor_template.tip' }, ], default: 'generic', tip: 'protocols.ipsec.fields.vendor_template.tip', group: 'basic' },
{ key: 'public_endpoint', label: 'protocols.ipsec.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true }, { key: 'public_endpoint', label: 'protocols.ipsec.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true, group: 'basic' },
{ key: 'remote_gateway', label: 'protocols.ipsec.fields.remote_gateway.label', placeholder: '5.6.7.8', required: true }, { key: 'remote_gateway', label: 'protocols.ipsec.fields.remote_gateway.label', placeholder: '5.6.7.8', required: true, group: 'basic' },
{ key: 'local_subnets', label: 'protocols.ipsec.fields.local_subnets.label', placeholder: '192.168.1.0/24', required: true, tip: 'protocols.ipsec.fields.local_subnets.tip' }, { key: 'local_subnets', label: 'protocols.ipsec.fields.local_subnets.label', placeholder: '192.168.1.0/24', required: true, tip: 'protocols.ipsec.fields.local_subnets.tip', group: 'routing' },
{ key: 'remote_subnets', label: 'protocols.ipsec.fields.remote_subnets.label', placeholder: '10.0.0.0/24', required: true }, { key: 'remote_subnets', label: 'protocols.ipsec.fields.remote_subnets.label', placeholder: '10.0.0.0/24', required: true, group: 'routing' },
{ key: 'psk', label: 'protocols.ipsec.fields.psk.label', type: 'password', required: true, secret: true }, { key: 'psk', label: 'protocols.ipsec.fields.psk.label', type: 'password', required: true, secret: true, group: 'basic' },
{ key: 'ike_version', label: 'protocols.ipsec.fields.ike_version.label', type: 'select', options: [ { key: 'ike_version', label: 'protocols.ipsec.fields.ike_version.label', type: 'select', options: [
{ value: 'ikev2', label: 'protocols.ipsec.fields.ike_version.options.ikev2' }, { value: 'ikev2', label: 'protocols.ipsec.fields.ike_version.options.ikev2' },
{ value: 'ikev1', label: 'protocols.ipsec.fields.ike_version.options.ikev1' } { value: 'ikev1', label: 'protocols.ipsec.fields.ike_version.options.ikev1' }
], default: 'ikev2' }, ], default: 'ikev2', group: 'crypto' },
{ key: 'ike_proposal', label: 'protocols.ipsec.fields.ike_proposal.label', placeholder: 'aes256-sha256-modp2048', tip: 'protocols.ipsec.fields.ike_proposal.tip' }, { key: 'ike_proposal', label: 'protocols.ipsec.fields.ike_proposal.label', placeholder: 'aes256-sha256-modp2048', tip: 'protocols.ipsec.fields.ike_proposal.tip', group: 'crypto' },
{ key: 'esp_proposal', label: 'protocols.ipsec.fields.esp_proposal.label', placeholder: 'aes256-sha256' }, { key: 'esp_proposal', label: 'protocols.ipsec.fields.esp_proposal.label', placeholder: 'aes256-sha256', group: 'crypto' },
{ key: 'pfs_group', label: 'protocols.ipsec.fields.pfs_group.label', default: 'modp2048' }, { key: 'pfs_group', label: 'protocols.ipsec.fields.pfs_group.label', default: 'modp2048', group: 'crypto' },
{ key: 'ike_lifetime', label: 'protocols.ipsec.fields.ike_lifetime.label', type: 'number', default: 86400 }, { key: 'ike_lifetime', label: 'protocols.ipsec.fields.ike_lifetime.label', type: 'number', default: 86400, group: 'advanced' },
{ key: 'local_id', label: 'protocols.ipsec.fields.local_id.label', placeholder: '@vpn-local' }, { key: 'local_id', label: 'protocols.ipsec.fields.local_id.label', placeholder: '@vpn-local', group: 'advanced' },
{ key: 'remote_id', label: 'protocols.ipsec.fields.remote_id.label', placeholder: '@vpn-remote' } { key: 'remote_id', label: 'protocols.ipsec.fields.remote_id.label', placeholder: '@vpn-remote', group: 'advanced' }
] ]
}, },
ikev2: { ikev2: {
@@ -59,16 +59,14 @@ export const PROTOCOL_META = {
icon: 'ikev2', icon: 'ikev2',
scenes: 'protocols.ikev2.scenes', scenes: 'protocols.ikev2.scenes',
fields: [ fields: [
{ key: 'public_endpoint', label: 'protocols.ikev2.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true }, { key: 'public_endpoint', label: 'protocols.ikev2.fields.public_endpoint.label', placeholder: 'vpn.example.com', required: true, group: 'basic' },
{ key: 'local_id', label: 'protocols.ikev2.fields.local_id.label', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: 'protocols.ikev2.fields.local_id.tip' }, { key: 'local_id', label: 'protocols.ikev2.fields.local_id.label', placeholder: '@opsx-ikev2', default: '@opsx-ikev2', tip: 'protocols.ikev2.fields.local_id.tip', group: 'basic' },
{ key: 'psk', label: 'protocols.ikev2.fields.psk.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.psk.tip' }, { key: 'psk', label: 'protocols.ikev2.fields.psk.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.psk.tip', group: 'basic' },
{ key: 'username', label: 'protocols.ikev2.fields.username.label', default: 'vpnuser' }, { key: 'address_pool', label: 'protocols.ikev2.fields.address_pool.label', default: '10.12.0.0/24', group: 'basic' },
{ key: 'password', label: 'protocols.ikev2.fields.password.label', type: 'password', secret: true, tip: 'protocols.ikev2.fields.password.tip' }, { key: 'dns', label: 'protocols.ikev2.fields.dns.label', default: '223.5.5.5', group: 'basic' },
{ key: 'address_pool', label: 'protocols.ikev2.fields.address_pool.label', default: '10.12.0.0/24' }, { key: 'local_ts', label: 'protocols.ikev2.fields.local_ts.label', default: '0.0.0.0/0', tip: 'protocols.ikev2.fields.local_ts.tip', group: 'routing' },
{ key: 'dns', label: 'protocols.ikev2.fields.dns.label', default: '223.5.5.5' }, { key: 'ike_proposal', label: 'protocols.ikev2.fields.ike_proposal.label', default: 'aes256-sha256-modp2048', group: 'crypto' },
{ key: 'local_ts', label: 'protocols.ikev2.fields.local_ts.label', default: '0.0.0.0/0', tip: 'protocols.ikev2.fields.local_ts.tip' }, { key: 'esp_proposal', label: 'protocols.ikev2.fields.esp_proposal.label', default: 'aes256-sha256', group: 'crypto' }
{ key: 'ike_proposal', label: 'protocols.ikev2.fields.ike_proposal.label', default: 'aes256-sha256-modp2048' },
{ key: 'esp_proposal', label: 'protocols.ikev2.fields.esp_proposal.label', default: 'aes256-sha256' }
] ]
}, },
l2tp: { l2tp: {
@@ -78,13 +76,29 @@ export const PROTOCOL_META = {
icon: 'l2tp', icon: 'l2tp',
scenes: 'protocols.l2tp.scenes', scenes: 'protocols.l2tp.scenes',
fields: [ fields: [
{ key: 'public_endpoint', label: 'protocols.l2tp.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true }, { key: 'public_endpoint', label: 'protocols.l2tp.fields.public_endpoint.label', placeholder: '1.2.3.4', required: true, group: 'basic' },
{ key: 'psk', label: 'protocols.l2tp.fields.psk.label', type: 'password', required: true, secret: true }, { key: 'psk', label: 'protocols.l2tp.fields.psk.label', type: 'password', secret: true, tip: 'protocols.l2tp.fields.psk.tip', group: 'basic' },
{ key: 'username', label: 'protocols.l2tp.fields.username.label', default: 'vpnuser', tip: 'protocols.l2tp.fields.username.tip' }, { key: 'ip_range', label: 'protocols.l2tp.fields.ip_range.label', default: '10.9.0.100-10.9.0.200', group: 'routing' },
{ key: 'password', label: 'protocols.l2tp.fields.password.label', type: 'password', secret: true, tip: 'protocols.l2tp.fields.password.tip' }, { key: 'local_ip', label: 'protocols.l2tp.fields.local_ip.label', default: '10.9.0.1', group: 'routing' },
{ key: 'ip_range', label: 'protocols.l2tp.fields.ip_range.label', default: '10.9.0.100-10.9.0.200' }, { key: 'dns', label: 'protocols.l2tp.fields.dns.label', default: '223.5.5.5', group: 'routing' }
{ key: 'local_ip', label: 'protocols.l2tp.fields.local_ip.label', default: '10.9.0.1' }, ]
{ key: 'dns', label: 'protocols.l2tp.fields.dns.label', default: '223.5.5.5' } },
gre: {
name: 'protocols.gre.name',
desc: 'protocols.gre.desc',
color: '#357a8b',
icon: 'gre',
scenes: 'protocols.gre.scenes',
fields: [
{ key: 'local_endpoint', label: 'protocols.gre.fields.local_endpoint.label', placeholder: '134.175.167.191', required: true, group: 'basic' },
{ key: 'remote_endpoint', label: 'protocols.gre.fields.remote_endpoint.label', placeholder: '203.0.113.10', required: true, group: 'basic' },
{ key: 'local_tunnel_ip', label: 'protocols.gre.fields.local_tunnel_ip.label', placeholder: '10.255.0.1/30', required: true, group: 'basic' },
{ key: 'remote_tunnel_ip', label: 'protocols.gre.fields.remote_tunnel_ip.label', placeholder: '10.255.0.2/30', required: true, group: 'basic' },
{ key: 'remote_subnet', label: 'protocols.gre.fields.remote_subnet.label', placeholder: '192.168.10.0/24', tip: 'protocols.gre.fields.remote_subnet.tip', group: 'routing' },
{ key: 'enable_nat', label: 'protocols.gre.fields.enable_nat.label', type: 'switch', default: false, tip: 'protocols.gre.fields.enable_nat.tip', group: 'routing' },
{ key: 'outbound_interface', label: 'protocols.gre.fields.outbound_interface.label', placeholder: 'eth0', showIf: { enable_nat: true }, group: 'routing' },
{ key: 'ttl', label: 'protocols.gre.fields.ttl.label', type: 'number', default: 255, group: 'advanced' },
{ key: 'mtu', label: 'protocols.gre.fields.mtu.label', type: 'number', default: 1476, tip: 'protocols.gre.fields.mtu.tip', group: 'advanced' }
] ]
}, },
frp: { frp: {
@@ -97,19 +111,19 @@ export const PROTOCOL_META = {
{ key: 'mode', label: 'protocols.frp.fields.mode.label', type: 'select', options: [ { key: 'mode', label: 'protocols.frp.fields.mode.label', type: 'select', options: [
{ value: 'server', label: 'protocols.frp.fields.mode.options.server' }, { value: 'server', label: 'protocols.frp.fields.mode.options.server' },
{ value: 'client', label: 'protocols.frp.fields.mode.options.client' } { value: 'client', label: 'protocols.frp.fields.mode.options.client' }
], default: 'server', tip: 'protocols.frp.fields.mode.tip' }, ], default: 'server', tip: 'protocols.frp.fields.mode.tip', group: 'basic' },
{ key: 'bind_port', label: 'protocols.frp.fields.bind_port.label', type: 'number', default: 7000, showIf: { mode: 'server' } }, { key: 'bind_port', label: 'protocols.frp.fields.bind_port.label', type: 'number', default: 7000, showIf: { mode: 'server' }, group: 'basic' },
{ key: 'public_endpoint', label: 'protocols.frp.fields.public_endpoint.label', placeholder: '1.2.3.4', showIf: { mode: 'server' } }, { key: 'public_endpoint', label: 'protocols.frp.fields.public_endpoint.label', placeholder: '1.2.3.4', showIf: { mode: 'server' }, group: 'basic' },
{ key: 'dashboard_port', label: 'protocols.frp.fields.dashboard_port.label', type: 'number', default: 7500, showIf: { mode: 'server' } }, { key: 'dashboard_port', label: 'protocols.frp.fields.dashboard_port.label', type: 'number', default: 7500, showIf: { mode: 'server' }, group: 'advanced' },
{ key: 'dashboard_user', label: 'protocols.frp.fields.dashboard_user.label', default: 'admin', showIf: { mode: 'server' } }, { key: 'dashboard_user', label: 'protocols.frp.fields.dashboard_user.label', default: 'admin', showIf: { mode: 'server' }, group: 'advanced' },
{ key: 'dashboard_password', label: 'protocols.frp.fields.dashboard_password.label', type: 'password', secret: true, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.dashboard_password.tip' }, { key: 'dashboard_password', label: 'protocols.frp.fields.dashboard_password.label', type: 'password', secret: true, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.dashboard_password.tip', group: 'advanced' },
{ key: 'vhost_http_port', label: 'protocols.frp.fields.vhost_http_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_http_port.tip' }, { key: 'vhost_http_port', label: 'protocols.frp.fields.vhost_http_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_http_port.tip', group: 'advanced' },
{ key: 'vhost_https_port', label: 'protocols.frp.fields.vhost_https_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_https_port.tip' }, { key: 'vhost_https_port', label: 'protocols.frp.fields.vhost_https_port.label', type: 'number', default: 0, showIf: { mode: 'server' }, tip: 'protocols.frp.fields.vhost_https_port.tip', group: 'advanced' },
{ key: 'allow_ports', label: 'protocols.frp.fields.allow_ports.label', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: 'protocols.frp.fields.allow_ports.tip' }, { key: 'allow_ports', label: 'protocols.frp.fields.allow_ports.label', placeholder: '6000-6100,7001', showIf: { mode: 'server' }, tip: 'protocols.frp.fields.allow_ports.tip', group: 'advanced' },
{ key: 'server_addr', label: 'protocols.frp.fields.server_addr.label', placeholder: '1.2.3.4', showIf: { mode: 'client' } }, { key: 'server_addr', label: 'protocols.frp.fields.server_addr.label', placeholder: '1.2.3.4', showIf: { mode: 'client' }, group: 'basic' },
{ key: 'server_port', label: 'protocols.frp.fields.server_port.label', type: 'number', default: 7000, showIf: { mode: 'client' } }, { key: 'server_port', label: 'protocols.frp.fields.server_port.label', type: 'number', default: 7000, showIf: { mode: 'client' }, group: 'basic' },
{ key: 'token', label: 'protocols.frp.fields.token.label', type: 'password', secret: true, tip: 'protocols.frp.fields.token.tip' }, { key: 'token', label: 'protocols.frp.fields.token.label', type: 'password', secret: true, tip: 'protocols.frp.fields.token.tip', group: 'basic' },
{ key: 'proxy_name', label: 'protocols.frp.fields.proxy_name.label', placeholder: 'ssh', default: 'ssh' }, { key: 'proxy_name', label: 'protocols.frp.fields.proxy_name.label', placeholder: 'ssh', default: 'ssh', group: 'basic' },
{ key: 'proxy_type', label: 'protocols.frp.fields.proxy_type.label', type: 'select', options: [ { key: 'proxy_type', label: 'protocols.frp.fields.proxy_type.label', type: 'select', options: [
{ value: 'tcp', label: 'protocols.frp.fields.proxy_type.options.tcp' }, { value: 'tcp', label: 'protocols.frp.fields.proxy_type.options.tcp' },
{ value: 'udp', label: 'protocols.frp.fields.proxy_type.options.udp' }, { value: 'udp', label: 'protocols.frp.fields.proxy_type.options.udp' },
@@ -117,12 +131,12 @@ export const PROTOCOL_META = {
{ value: 'https', label: 'protocols.frp.fields.proxy_type.options.https' }, { value: 'https', label: 'protocols.frp.fields.proxy_type.options.https' },
{ value: 'stcp', label: 'protocols.frp.fields.proxy_type.options.stcp' }, { value: 'stcp', label: 'protocols.frp.fields.proxy_type.options.stcp' },
{ value: 'xtcp', label: 'protocols.frp.fields.proxy_type.options.xtcp' } { value: 'xtcp', label: 'protocols.frp.fields.proxy_type.options.xtcp' }
], default: 'tcp' }, ], default: 'tcp', group: 'basic' },
{ key: 'local_ip', label: 'protocols.frp.fields.local_ip.label', default: '127.0.0.1' }, { key: 'local_ip', label: 'protocols.frp.fields.local_ip.label', default: '127.0.0.1', group: 'basic' },
{ key: 'local_port', label: 'protocols.frp.fields.local_port.label', type: 'number', default: 22 }, { key: 'local_port', label: 'protocols.frp.fields.local_port.label', type: 'number', default: 22, group: 'basic' },
{ key: 'remote_port', label: 'protocols.frp.fields.remote_port.label', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } }, { key: 'remote_port', label: 'protocols.frp.fields.remote_port.label', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] }, group: 'basic' },
{ key: 'custom_domains', label: 'protocols.frp.fields.custom_domains.label', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'protocols.frp.fields.custom_domains.tip' }, { key: 'custom_domains', label: 'protocols.frp.fields.custom_domains.label', placeholder: 'app.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'protocols.frp.fields.custom_domains.tip', group: 'basic' },
{ key: 'secret_key', label: 'protocols.frp.fields.secret_key.label', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: 'protocols.frp.fields.secret_key.tip' } { key: 'secret_key', label: 'protocols.frp.fields.secret_key.label', type: 'password', secret: true, showIf: { proxy_type: ['stcp', 'xtcp'] }, tip: 'protocols.frp.fields.secret_key.tip', group: 'basic' }
] ]
}, },
openvpn: { openvpn: {
@@ -137,7 +151,7 @@ export const PROTOCOL_META = {
{ key: 'proto', label: 'protocols.openvpn.fields.proto.label', type: 'select', options: [ { key: 'proto', label: 'protocols.openvpn.fields.proto.label', type: 'select', options: [
{ value: 'udp', label: 'protocols.openvpn.fields.proto.options.udp' }, { value: 'udp', label: 'protocols.openvpn.fields.proto.options.udp' },
{ value: 'tcp-server', label: 'protocols.openvpn.fields.proto.options.tcp-server' } { value: 'tcp-server', label: 'protocols.openvpn.fields.proto.options.tcp-server' }
], default: 'udp', tip: 'protocols.openvpn.fields.proto.tip' }, ], default: 'tcp-server', tip: 'protocols.openvpn.fields.proto.tip' },
{ key: 'subnet', label: 'protocols.openvpn.fields.subnet.label', default: '10.10.0.0 255.255.255.0', tip: 'protocols.openvpn.fields.subnet.tip' }, { key: 'subnet', label: 'protocols.openvpn.fields.subnet.label', default: '10.10.0.0 255.255.255.0', tip: 'protocols.openvpn.fields.subnet.tip' },
{ key: 'cipher', label: 'protocols.openvpn.fields.cipher.label', type: 'select', options: [ { key: 'cipher', label: 'protocols.openvpn.fields.cipher.label', type: 'select', options: [
{ value: 'AES-256-GCM', label: 'protocols.openvpn.fields.cipher.options.AES-256-GCM' }, { value: 'AES-256-GCM', label: 'protocols.openvpn.fields.cipher.options.AES-256-GCM' },
@@ -148,7 +162,7 @@ export const PROTOCOL_META = {
{ value: 'certificate', label: 'protocols.openvpn.fields.auth_mode.options.certificate' }, { value: 'certificate', label: 'protocols.openvpn.fields.auth_mode.options.certificate' },
{ value: 'password', label: 'protocols.openvpn.fields.auth_mode.options.password' }, { value: 'password', label: 'protocols.openvpn.fields.auth_mode.options.password' },
{ value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' } { value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' }
], default: 'certificate', tip: 'protocols.openvpn.fields.auth_mode.tip' }, ], default: 'password', tip: 'protocols.openvpn.fields.auth_mode.tip' },
{ key: 'mfa_mode', label: 'protocols.openvpn.fields.mfa_mode.label', type: 'select', options: [ { key: 'mfa_mode', label: 'protocols.openvpn.fields.mfa_mode.label', type: 'select', options: [
{ value: 'off', label: 'protocols.openvpn.fields.mfa_mode.options.off' }, { value: 'off', label: 'protocols.openvpn.fields.mfa_mode.options.off' },
{ value: 'optional', label: 'protocols.openvpn.fields.mfa_mode.options.optional' }, { value: 'optional', label: 'protocols.openvpn.fields.mfa_mode.options.optional' },
@@ -161,7 +175,7 @@ export const PROTOCOL_META = {
{ key: 'dns', label: 'protocols.openvpn.fields.dns.label', default: '223.5.5.5' }, { key: 'dns', label: 'protocols.openvpn.fields.dns.label', default: '223.5.5.5' },
{ key: 'redirect_gateway', label: 'protocols.openvpn.fields.redirect_gateway.label', type: 'switch', default: true, tip: 'protocols.openvpn.fields.redirect_gateway.tip' }, { key: 'redirect_gateway', label: 'protocols.openvpn.fields.redirect_gateway.label', type: 'switch', default: true, tip: 'protocols.openvpn.fields.redirect_gateway.tip' },
{ key: 'push_routes', label: 'protocols.openvpn.fields.push_routes.label', placeholder: '192.168.0.0/16,10.0.0.0/8', tip: 'protocols.openvpn.fields.push_routes.tip' }, { key: 'push_routes', label: 'protocols.openvpn.fields.push_routes.label', placeholder: '192.168.0.0/16,10.0.0.0/8', tip: 'protocols.openvpn.fields.push_routes.tip' },
{ key: 'enable_nat', label: 'protocols.openvpn.fields.enable_nat.label', type: 'switch', default: false }, { key: 'enable_nat', label: 'protocols.openvpn.fields.enable_nat.label', type: 'switch', default: true },
{ key: 'max_clients', label: 'protocols.openvpn.fields.max_clients.label', type: 'number', default: 1000, tip: 'protocols.openvpn.fields.max_clients.tip' }, { key: 'max_clients', label: 'protocols.openvpn.fields.max_clients.label', type: 'number', default: 1000, tip: 'protocols.openvpn.fields.max_clients.tip' },
{ key: 'keepalive_interval', label: 'protocols.openvpn.fields.keepalive_interval.label', type: 'number', default: 5, tip: 'protocols.openvpn.fields.keepalive_interval.tip' }, { key: 'keepalive_interval', label: 'protocols.openvpn.fields.keepalive_interval.label', type: 'number', default: 5, tip: 'protocols.openvpn.fields.keepalive_interval.tip' },
{ key: 'keepalive_timeout', label: 'protocols.openvpn.fields.keepalive_timeout.label', type: 'number', default: 30 }, { key: 'keepalive_timeout', label: 'protocols.openvpn.fields.keepalive_timeout.label', type: 'number', default: 30 },
@@ -188,6 +202,7 @@ export const PROTOCOL_COLORS = {
ipsec: '#1f6fb2', ipsec: '#1f6fb2',
ikev2: '#0d7377', ikev2: '#0d7377',
l2tp: '#4a8a2c', l2tp: '#4a8a2c',
gre: '#357a8b',
frp: '#b8651b', frp: '#b8651b',
openvpn: '#cc6529' openvpn: '#cc6529'
} }
-2
View File
@@ -16,10 +16,8 @@ const routes = [
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } }, { path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } }, { path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } }, { path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { titleKey: 'router.nodes', requiresAdmin: true } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } }, { path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } }, { path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true } },
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { titleKey: 'router.plugins', requiresAdmin: true } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } }, { path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } },
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } } { path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
] ]
+49 -18
View File
@@ -28,6 +28,9 @@
</el-col> </el-col>
</el-row> </el-row>
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint" />
<el-row :gutter="16" style="margin-top:16px"> <el-row :gutter="16" style="margin-top:16px">
<el-col :xs="24" :lg="10"> <el-col :xs="24" :lg="10">
<el-card class="page-card" shadow="never"> <el-card class="page-card" shadow="never">
@@ -83,6 +86,23 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<el-dialog v-model="installDialogVisible" :title="`${installDialogTitle} ${$t('dashboard.installCmd')}`" width="620px" align-center>
<el-alert :type="installData.detected ? 'success' : 'warning'" :closable="false" show-icon
:title="installData.detected ? $t('dashboard.depReady') : $t('dashboard.contactAdmin')" />
<div v-if="installData.command" class="install-command">
<div class="install-command-head"><b>{{ $t('dashboard.recommendedCmd') }}</b><el-tag size="small">{{ installData.pm || $t('dashboard.aptDefault') }}</el-tag></div>
<pre class="mono command-block">{{ installData.command }}</pre>
<el-button size="small" @click="copyText(installData.command)">{{ $t('common.copy') }}</el-button>
</div>
<template v-if="Object.keys(installData.all_commands).length">
<el-divider>{{ $t('dashboard.otherDistros') }}</el-divider>
<div v-for="(cmd, pm) in installData.all_commands" :key="pm" v-show="cmd !== installData.command" class="other-command">
<el-tag size="small">{{ pm }}</el-tag><code>{{ cmd }}</code><el-button link size="small" @click="copyText(cmd)">{{ $t('common.copy') }}</el-button>
</div>
</template>
<template #footer><el-button type="primary" @click="installDialogVisible = false">{{ $t('common.close') }}</el-button></template>
</el-dialog>
</div> </div>
</template> </template>
@@ -94,11 +114,13 @@ import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons-
import api from '../api' import api from '../api'
import { fmtBytes } from '../utils/format' import { fmtBytes } from '../utils/format'
import { protocolColor, protocolDisplay } from '../protocols' import { protocolColor, protocolDisplay } from '../protocols'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
const { t } = useI18n() const { t } = useI18n()
const ov = ref({}) const ov = ref({})
const protocols = ref([]) const protocols = ref([])
const readiness = ref({})
const readinessLoaded = ref(false)
const loadingProto = ref(true) const loadingProto = ref(true)
const pieRef = ref(null) const pieRef = ref(null)
let chart = null let chart = null
@@ -107,6 +129,9 @@ let chart = null
const deployableProtocols = ['frp', 'openvpn'] const deployableProtocols = ['frp', 'openvpn']
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' } const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
const deploying = ref('') const deploying = ref('')
const installDialogVisible = ref(false)
const installDialogTitle = ref('')
const installData = ref({ command: '', pm: '', detected: false, all_commands: {} })
async function deployProtocol(name) { async function deployProtocol(name) {
const pkg = protocolToPkg[name] || name const pkg = protocolToPkg[name] || name
@@ -122,29 +147,18 @@ async function deployProtocol(name) {
async function showInstallCmd(name) { async function showInstallCmd(name) {
const pkg = protocolToPkg[name] || name const pkg = protocolToPkg[name] || name
installDialogTitle.value = name
try { try {
const { data } = await api.get(`/binaries/install-hint/${pkg}`) const { data } = await api.get(`/binaries/install-hint/${pkg}`)
const cmd = data.command || t('dashboard.contactAdmin') installData.value = { command: data.command || '', pm: data.pm || '', detected: !!data.detected, all_commands: data.all_commands || {} }
const pmLabel = data.pm ? `(${data.pm})` : `(${t('dashboard.aptDefault')})` installDialogVisible.value = true
// 构建其他平台的命令列表
let otherCmds = ''
if (data.all_commands) {
for (const [pm, c] of Object.entries(data.all_commands)) {
if (c !== cmd) {
otherCmds += `<div style="margin:4px 0"><span style="display:inline-block;min-width:70px;font-weight:600">${pm}:</span> <code style="font-size:12px">${c}</code></div>`
}
}
}
const otherSection = otherCmds ? `<hr style="margin:12px 0;border:none;border-top:1px solid #e8eaee"><div style="font-size:12px;color:#909399;margin-bottom:6px">${t('dashboard.otherDistros')}</div>${otherCmds}` : ''
ElMessageBox.alert(
`<div style="margin-bottom:8px"><b>${t('dashboard.recommendedCmd')}${pmLabel}</b></div><pre style="background:#f0f9eb;border:1px solid #67c23a;padding:12px;border-radius:6px;font-family:monospace;white-space:pre-wrap;font-size:13px">${cmd}</pre>${otherSection}`,
`${name} ${t('dashboard.installCmd')}`, { dangerouslyUseHTMLString: true, confirmButtonText: t('dashboard.copyRecommended'),
callback: (action) => { if (action === 'confirm') navigator.clipboard.writeText(cmd) } })
} catch { } catch {
ElMessage.error(t('dashboard.getInstallCmdFailed')) ElMessage.error(t('dashboard.getInstallCmdFailed'))
} }
} }
function copyText(text) { navigator.clipboard.writeText(text).then(() => ElMessage.success(t('common.copied'))) }
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' } function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
const statCards = computed(() => [ const statCards = computed(() => [
@@ -153,14 +167,24 @@ const statCards = computed(() => [
{ label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' }, { label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' },
{ label: t('dashboard.totalTraffic'), value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } }, { label: t('dashboard.totalTraffic'), value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } },
]) ])
const hostReady = computed(() => readiness.value.root && readiness.value.ipv4_forwarding && readiness.value.default_route_ready)
const readinessHint = computed(() => {
const missing = []
if (!readiness.value.root) missing.push(t('dashboard.readinessRoot'))
if (!readiness.value.ipv4_forwarding) missing.push(t('dashboard.readinessForwarding'))
if (!readiness.value.default_route_ready) missing.push(t('dashboard.readinessRoute'))
return missing.join(' / ')
})
async function load() { async function load() {
// 轮询可见性守卫:标签页隐藏时跳过(P1 修复) // 轮询可见性守卫:标签页隐藏时跳过(P1 修复)
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
try { try {
const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')]) const [{ data: o }, { data: p }, { data: r }] = await Promise.all([api.get('/overview'), api.get('/protocols'), api.get('/readiness')])
ov.value = o ov.value = o
protocols.value = p protocols.value = p
readiness.value = r || {}
readinessLoaded.value = true
renderPie() renderPie()
} catch { /* 忽略轮询错误 */ } finally { loadingProto.value = false } } catch { /* 忽略轮询错误 */ } finally { loadingProto.value = false }
} }
@@ -235,4 +259,11 @@ onUnmounted(() => {
.stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } .stat-num { font-size: 26px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; } .dep-hint { font-size: 12px; color: var(--text-secondary); font-weight: normal; }
.host-readiness { margin-top: var(--space-4); }
.install-command { margin-top: var(--space-4); }
.install-command-head, .other-command { display:flex; align-items:center; gap:8px; }
.install-command-head { justify-content:space-between; }
.command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; }
.other-command { margin-bottom:8px; }
.other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; }
</style> </style>
+1 -3
View File
@@ -14,9 +14,7 @@
<el-menu-item index="/logs"><el-icon><Document /></el-icon>{{ $t('router.logs') }}</el-menu-item> <el-menu-item index="/logs"><el-icon><Document /></el-icon>{{ $t('router.logs') }}</el-menu-item>
</el-menu-item-group> </el-menu-item-group>
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')"> <el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>{{ $t('router.nodes') }}</el-menu-item>
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item> <el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item>
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>{{ $t('router.plugins') }}</el-menu-item>
</el-menu-item-group> </el-menu-item-group>
<el-menu-item-group :title="$t('router.sidebarSystem')"> <el-menu-item-group :title="$t('router.sidebarSystem')">
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item> <el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item>
@@ -94,7 +92,7 @@ import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue' import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue'
import api, { auth } from '../api' import api, { auth } from '../api'
const sideBg = 'var(--bg-sidebar)' const sideBg = 'var(--bg-sidebar)'
+64 -1
View File
@@ -17,7 +17,8 @@
<el-form-item :label="$t('tunnelEditor.protocolType')"> <el-form-item :label="$t('tunnelEditor.protocolType')">
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ $t(protocolDisplay(protocol)) }}</el-tag> <el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ $t(protocolDisplay(protocol)) }}</el-tag>
</el-form-item> </el-form-item>
<el-form-item v-for="field in visibleFields" :key="field.key" :label="$t(field.label)" :required="field.required"> <el-divider v-if="isOpenVPN" content-position="left" class="config-section-divider">{{ $t('tunnelEditor.coreOptions') }}</el-divider>
<el-form-item v-for="field in coreFields" :key="field.key" :label="$t(field.label)" :required="field.required">
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" /> <el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" /> <el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%"> <el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
@@ -28,6 +29,34 @@
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" /> <el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div> <div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
</el-form-item> </el-form-item>
<template v-if="isOpenVPN">
<el-form-item :label="$t('tunnelEditor.trafficMode')">
<el-radio-group v-model="trafficMode">
<el-radio-button value="gateway">{{ $t('tunnelEditor.trafficGateway') }}</el-radio-button>
<el-radio-button value="static">{{ $t('tunnelEditor.trafficStatic') }}</el-radio-button>
</el-radio-group>
<div class="field-tip">{{ $t(`tunnelEditor.traffic${trafficMode[0].toUpperCase()}${trafficMode.slice(1)}Tip`) }}</div>
</el-form-item>
<el-form-item v-if="trafficMode === 'static'" :label="$t('protocols.openvpn.fields.push_routes.label')">
<RouteListInput ref="routeListRef" v-model="form.config.push_routes" />
<div class="field-tip">{{ $t('protocols.openvpn.fields.push_routes.tip') }}</div>
</el-form-item>
</template>
<el-collapse v-if="advancedFields.length" v-model="advancedOpen" class="advanced-options">
<el-collapse-item :title="$t('tunnelEditor.advancedOptions')" name="advanced">
<el-form-item v-for="field in advancedFields" :key="field.key" :label="$t(field.label)" :required="field.required">
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" />
</el-select>
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
</el-form-item>
</el-collapse-item>
</el-collapse>
<el-form-item :label="$t('tunnelEditor.expireTime')"> <el-form-item :label="$t('tunnelEditor.expireTime')">
<el-date-picker v-model="form.expire_at" type="datetime" clearable :placeholder="$t('tunnelEditor.expirePlaceholder')" style="width:100%" /> <el-date-picker v-model="form.expire_at" type="datetime" clearable :placeholder="$t('tunnelEditor.expirePlaceholder')" style="width:100%" />
</el-form-item> </el-form-item>
@@ -47,6 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api from '../api' import api from '../api'
import RouteListInput from '../components/RouteListInput.vue'
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols' import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
const { t } = useI18n() const { t } = useI18n()
@@ -56,6 +86,8 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const protocol = ref('') const protocol = ref('')
const form = ref(null) const form = ref(null)
const advancedOpen = ref([])
const routeListRef = ref(null)
const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS) const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)
const visibleFields = computed(() => fields.value.filter(field => { const visibleFields = computed(() => fields.value.filter(field => {
@@ -64,6 +96,33 @@ const visibleFields = computed(() => fields.value.filter(field => {
? value.includes(form.value.config[key]) ? value.includes(form.value.config[key])
: form.value.config[key] === value) : form.value.config[key] === value)
})) }))
const isOpenVPN = computed(() => protocol.value === 'openvpn')
const openVPNCoreKeys = new Set(['public_endpoint', 'port', 'proto', 'auth_mode', 'mfa_mode'])
const openVPNTrafficKeys = new Set(['redirect_gateway', 'push_routes', 'enable_nat'])
const trafficMode = computed({
get() {
const cfg = form.value?.config || {}
return cfg.redirect_gateway === false ? 'static' : 'gateway'
},
set(mode) {
if (!form.value) return
if (mode === 'gateway') {
form.value.config.redirect_gateway = true
form.value.config.enable_nat = true
form.value.config.push_routes = ''
} else if (mode === 'static') {
form.value.config.redirect_gateway = false
form.value.config.enable_nat = false
}
},
})
const coreFields = computed(() => isOpenVPN.value
? visibleFields.value.filter(field => openVPNCoreKeys.has(field.key))
: visibleFields.value.filter(field => field.group !== 'advanced' && field.group !== 'crypto'))
const advancedFields = computed(() => {
if (!isOpenVPN.value) return visibleFields.value.filter(field => field.group === 'advanced' || field.group === 'crypto')
return visibleFields.value.filter(field => !openVPNCoreKeys.has(field.key) && !openVPNTrafficKeys.has(field.key))
})
function parseConfig(raw) { function parseConfig(raw) {
try { return JSON.parse(raw || '{}') } catch { return {} } try { return JSON.parse(raw || '{}') } catch { return {} }
@@ -95,6 +154,8 @@ async function load() {
async function save() { async function save() {
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName')) if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
trafficMode.value = trafficMode.value
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
saving.value = true saving.value = true
try { try {
await api.put(`/tunnels/${route.params.id}`, form.value) await api.put(`/tunnels/${route.params.id}`, form.value)
@@ -112,4 +173,6 @@ onMounted(load)
<style scoped> <style scoped>
.field-tip { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.4; } .field-tip { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.4; }
.config-section-divider { margin: 4px 0 18px; }
.advanced-options { margin: 12px 0 18px; }
</style> </style>
+62 -13
View File
@@ -47,6 +47,9 @@
<el-tag :color="currentColor" effect="dark" style="border:none">{{ $t(currentName) }}</el-tag> <el-tag :color="currentColor" effect="dark" style="border:none">{{ $t(currentName) }}</el-tag>
<el-button link style="margin-left:10px" @click="step = 0">{{ $t('tunnelWizard.reselect') }}</el-button> <el-button link style="margin-left:10px" @click="step = 0">{{ $t('tunnelWizard.reselect') }}</el-button>
</el-form-item> </el-form-item>
<el-divider v-if="isOpenVPN" content-position="left" class="config-section-divider">
{{ $t('tunnelWizard.coreOptions') }}
</el-divider>
<!-- 基础字段 --> <!-- 基础字段 -->
<el-form-item v-for="f in coreFields" :key="f.key" :label="$t(f.label)" :required="f.required"> <el-form-item v-for="f in coreFields" :key="f.key" :label="$t(f.label)" :required="f.required">
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" /> <el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
@@ -59,6 +62,19 @@
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" /> <el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
<div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div> <div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div>
</el-form-item> </el-form-item>
<template v-if="isOpenVPN">
<el-form-item :label="$t('tunnelWizard.trafficMode')">
<el-radio-group v-model="trafficMode">
<el-radio-button value="gateway">{{ $t('tunnelWizard.trafficGateway') }}</el-radio-button>
<el-radio-button value="static">{{ $t('tunnelWizard.trafficStatic') }}</el-radio-button>
</el-radio-group>
<div class="field-tip">{{ $t(`tunnelWizard.traffic${trafficMode[0].toUpperCase()}${trafficMode.slice(1)}Tip`) }}</div>
</el-form-item>
<el-form-item v-if="trafficMode === 'static'" :label="$t('protocols.openvpn.fields.push_routes.label')">
<RouteListInput ref="routeListRef" v-model="form.config.push_routes" />
<div class="field-tip">{{ $t('protocols.openvpn.fields.push_routes.tip') }}</div>
</el-form-item>
</template>
<!-- 高级字段(可折叠) --> <!-- 高级字段(可折叠) -->
<template v-if="advancedFields.length"> <template v-if="advancedFields.length">
@@ -142,6 +158,7 @@ import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import api from '../api' import api from '../api'
import ProtocolIcon from '../components/ProtocolIcon.vue' import ProtocolIcon from '../components/ProtocolIcon.vue'
import RouteListInput from '../components/RouteListInput.vue'
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols' import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
const { t } = useI18n() const { t } = useI18n()
@@ -154,6 +171,7 @@ const depStatus = ref({})
const customProtocols = ref([]) const customProtocols = ref([])
const qrCanvas = ref(null) const qrCanvas = ref(null)
const qrShown = ref(false) const qrShown = ref(false)
const routeListRef = ref(null)
const builtin = PROTOCOL_META const builtin = PROTOCOL_META
@@ -167,13 +185,32 @@ const visibleFields = computed(() => currentFields.value.filter(f => {
? v.includes(form.value.config[k]) ? v.includes(form.value.config[k])
: form.value.config[k] === v) : form.value.config[k] === v)
})) }))
const isOpenVPN = computed(() => form.value.protocol === 'openvpn')
const openVPNCoreKeys = new Set(['public_endpoint', 'port', 'proto', 'auth_mode', 'mfa_mode'])
const openVPNTrafficKeys = new Set(['redirect_gateway', 'push_routes', 'enable_nat'])
const trafficMode = computed({
get() {
const cfg = form.value.config
return cfg.redirect_gateway === false ? 'static' : 'gateway'
},
set(mode) {
if (mode === 'gateway') {
form.value.config.redirect_gateway = true
form.value.config.enable_nat = true
form.value.config.push_routes = ''
} else if (mode === 'static') {
form.value.config.redirect_gateway = false
form.value.config.enable_nat = false
}
},
})
const coreFields = computed(() => { const coreFields = computed(() => {
if (visibleFields.value.length <= 6) return visibleFields.value if (isOpenVPN.value) return visibleFields.value.filter(f => openVPNCoreKeys.has(f.key))
return visibleFields.value.filter(f => f.required) return visibleFields.value.filter(f => f.group !== 'advanced' && f.group !== 'crypto')
}) })
const advancedFields = computed(() => { const advancedFields = computed(() => {
if (visibleFields.value.length <= 6) return [] if (isOpenVPN.value) return visibleFields.value.filter(f => !openVPNCoreKeys.has(f.key) && !openVPNTrafficKeys.has(f.key))
return visibleFields.value.filter(f => !f.required) return visibleFields.value.filter(f => f.group === 'advanced' || f.group === 'crypto')
}) })
const advCollapsed = ref([]) const advCollapsed = ref([])
@@ -188,6 +225,8 @@ function selectProtocol(key) {
async function create() { async function create() {
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName')) if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
trafficMode.value = trafficMode.value
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
for (const f of visibleFields.value) { for (const f of visibleFields.value) {
if (f.required) { if (f.required) {
const val = form.value.config[f.key] const val = form.value.config[f.key]
@@ -208,21 +247,30 @@ async function create() {
}) })
created.value = data created.value = data
const detail = await api.get(`/tunnels/${data.id}`) const detail = await api.get(`/tunnels/${data.id}`)
clientConfig.value = detail.data.client_config || '' clientConfig.value = detail.data.client_config || detail.data.client_config_error || ''
step.value = 2 step.value = 2
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnelWizard.createFailed')) ElMessage.error(e.response?.data?.error || t('tunnelWizard.createFailed'))
} finally { creating.value = false } } finally { creating.value = false }
} }
function downloadCreated() { async function downloadCreated() {
fetch(`/api/tunnels/${created.value.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } }) if (!created.value) return
.then(r => r.blob()).then(b => { try {
const a = document.createElement('a') const res = await fetch(`/api/tunnels/${created.value.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
a.href = URL.createObjectURL(b) if (!res.ok) {
a.download = `${created.value.protocol}-${created.value.name}.conf` const body = await res.json().catch(() => ({}))
a.click() throw new Error(body.error || t('tunnelWizard.createFailed'))
}) }
const a = document.createElement('a')
a.href = URL.createObjectURL(await res.blob())
const ext = created.value.protocol === 'openvpn' ? 'ovpn' : 'conf'
a.download = `${created.value.protocol}-${created.value.name}.${ext}`
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error(e.message || t('tunnelWizard.createFailed'))
}
} }
async function showQr() { async function showQr() {
@@ -316,6 +364,7 @@ onMounted(async () => {
.proto-scene { font-size: 11px; color: #909399; margin-top: 6px; } .proto-scene { font-size: 11px; color: #909399; margin-top: 6px; }
.dep-badge { margin-top: 8px; } .dep-badge { margin-top: 8px; }
.field-tip { font-size: 12px; color: var(--text-secondary); line-height: 1.4; } .field-tip { font-size: 12px; color: var(--text-secondary); line-height: 1.4; }
.config-section-divider { margin: 8px 0 20px; }
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 14px; max-height: 320px; overflow: auto; } .conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 14px; max-height: 320px; overflow: auto; }
.cmd-label { display: flex; align-items: center; margin-bottom: 6px; } .cmd-label { display: flex; align-items: center; margin-bottom: 6px; }
.cmd-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .cmd-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
+10 -9
View File
@@ -13,7 +13,12 @@
<el-option :label="$t('tunnels.stateError')" value="error" /> <el-option :label="$t('tunnels.stateError')" value="error" />
</el-select> </el-select>
</div> </div>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button> <div style="display:flex;align-items:center;gap:8px">
<el-tooltip :content="$t('common.refresh')" placement="top">
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
</el-tooltip>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
</div>
</div> </div>
</template> </template>
@@ -43,9 +48,7 @@
</el-table-column> </el-table-column>
<el-table-column :label="$t('common.status')" width="110"> <el-table-column :label="$t('common.status')" width="110">
<template #default="{ row }"> <template #default="{ row }">
<el-badge is-dot :type="dotType(row)" style="padding-left:12px"> <span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
</el-badge>
<el-tooltip v-if="row.runtime?.detail" :content="row.runtime.detail" placement="top"> <el-tooltip v-if="row.runtime?.detail" :content="row.runtime.detail" placement="top">
<el-icon style="margin-left:4px;color:#909399;cursor:help"><InfoFilled /></el-icon> <el-icon style="margin-left:4px;color:#909399;cursor:help"><InfoFilled /></el-icon>
</el-tooltip> </el-tooltip>
@@ -225,7 +228,7 @@
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Plus, InfoFilled, ArrowDown } from '@element-plus/icons-vue' import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import api from '../api' import api from '../api'
@@ -297,9 +300,6 @@ function stateText(row) {
function stateColor(row) { function stateColor(row) {
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)] return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
} }
function dotType(row) {
return { running: 'success', stopped: 'info', error: 'danger', undeployed: 'warning' }[stateOf(row)] || 'info'
}
function stateOf(row) { return row.runtime?.state || row.state } function stateOf(row) { return row.runtime?.state || row.state }
function prettyConfig(c) { function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c } try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
@@ -565,7 +565,8 @@ function download(row) {
const a = document.createElement('a') const a = document.createElement('a')
const url = URL.createObjectURL(b) const url = URL.createObjectURL(b)
a.href = url a.href = url
a.download = `${row.protocol}-${row.name}.conf` const ext = row.protocol === 'openvpn' ? 'ovpn' : 'conf'
a.download = `${row.protocol}-${row.name}.${ext}`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}) })
+150 -84
View File
@@ -1,17 +1,32 @@
<template> <template>
<el-card class="page-card" shadow="never"> <el-card class="page-card" shadow="never">
<template #header> <template #header>
<div style="display:flex;justify-content:space-between;align-items:center"> <div class="vpn-header">
<div> <div>
<b>{{ $t('vpnUsers.title') }}</b> <b>{{ $t('vpnUsers.title') }}</b>
<span style="margin-left:8px;color:#909399;font-size:12px"> <span style="margin-left:8px;color:#909399;font-size:12px">
{{ $t('vpnUsers.mfaHint') }} {{ $t('vpnUsers.mfaHint') }}
</span> </span>
</div> </div>
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button> <div class="vpn-header-actions">
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
<el-button @click="openBulkImport">{{ $t('vpnUsers.bulkImport') }}</el-button>
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
</div>
</div> </div>
</template> </template>
<el-table :data="users" size="small"> <div class="vpn-toolbar">
<el-input v-model="keyword" :placeholder="$t('vpnUsers.searchPlaceholder')" clearable style="width:220px" />
<el-select v-model="tunnelFilter" clearable :placeholder="$t('vpnUsers.allTunnels')" style="width:210px">
<el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" />
</el-select>
</div>
<div v-if="selected.length" class="vpn-batch-bar">
<span>{{ $t('vpnUsers.selectedUsers', { count: selected.length }) }}</span>
<el-button type="danger" size="small" @click="batchRemove">{{ $t('vpnUsers.batchDelete') }}</el-button>
</div>
<el-table :data="filteredUsers" size="small" v-loading="loading" @selection-change="rows => selected = rows">
<el-table-column type="selection" width="44" />
<el-table-column prop="username" :label="$t('common.username')" /> <el-table-column prop="username" :label="$t('common.username')" />
<el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" /> <el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" />
<el-table-column :label="$t('vpnUsers.tunnel')" min-width="160"> <el-table-column :label="$t('vpnUsers.tunnel')" min-width="160">
@@ -64,24 +79,11 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="$t('common.username')" required> <el-form-item :label="$t('common.username')" required>
<el-input v-model="form.username"> <el-input v-model="form.username" />
<template #append>
<el-button @click="openPlatformPicker">{{ $t('vpnUsers.pickFromUsers') }}</el-button>
</template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item :label="$t('vpnUsers.displayNameLabel')"><el-input v-model="form.display_name" /></el-form-item> <el-form-item :label="$t('vpnUsers.displayNameLabel')"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item :label="$t('vpnUsers.authSource')"> <el-form-item :label="$t('common.password')" required>
<el-radio-group v-model="form.source"> <el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
<el-radio-button value="local">{{ $t('common.local') }}</el-radio-button>
<el-radio-button value="ldap">{{ $t('common.ldap') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.source === 'local'" :label="$t('common.password')" required>
<el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
</el-form-item>
<el-form-item v-else :label="$t('vpnUsers.ldapDn')" required>
<el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('common.email')"><el-input v-model="form.email" /></el-form-item> <el-form-item :label="$t('common.email')"><el-input v-model="form.email" /></el-form-item>
</el-form> </el-form>
@@ -91,10 +93,14 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 从平台用户选择对话框 --> <el-dialog v-model="pickerVisible" :title="$t('vpnUsers.bulkImportTitle')" width="680px" align-center>
<el-dialog v-model="pickerVisible" :title="$t('vpnUsers.pickUserTitle')" width="560px" align-center> <el-alert type="info" :closable="false" style="margin-bottom:12px" :title="$t('vpnUsers.bulkImportHint')" />
<el-select v-model="form.tunnel_id" style="width:100%;margin-bottom:12px" :placeholder="$t('vpnUsers.selectOpenVPNTunnel')">
<el-option v-for="t in tunnels.filter(item => item.protocol === 'openvpn')" :key="t.id" :value="t.id" :label="t.name" />
</el-select>
<el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading" <el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading"
@row-click="pickPlatformUser"> @selection-change="rows => platformSelected = rows">
<el-table-column type="selection" width="44" />
<el-table-column prop="username" :label="$t('common.username')" width="120" /> <el-table-column prop="username" :label="$t('common.username')" width="120" />
<el-table-column prop="email" :label="$t('common.email')" min-width="160" /> <el-table-column prop="email" :label="$t('common.email')" min-width="160" />
<el-table-column :label="$t('common.source')" width="80"> <el-table-column :label="$t('common.source')" width="80">
@@ -104,58 +110,39 @@
</el-table-column> </el-table-column>
<template #empty><el-empty :description="$t('vpnUsers.noPlatformUsers')" /></template> <template #empty><el-empty :description="$t('vpnUsers.noPlatformUsers')" /></template>
</el-table> </el-table>
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">{{ $t('vpnUsers.clickRowHint') }}</p> <template #footer>
<el-button @click="pickerVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="importing" @click="importPlatformUsers">{{ $t('vpnUsers.importSelected') }}</el-button>
</template>
</el-dialog> </el-dialog>
<!-- OTP 双因素认证管理 --> <!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" :title="$t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' })" width="520px"> <el-dialog v-model="otpVisible" width="620px" class="mfa-dialog" destroy-on-close>
<el-alert type="info" :closable="false" style="margin-bottom:12px" <template #header>
:title="$t('vpnUsers.otpHint')" /> <div class="mfa-dialog-title">
<div class="mfa-title-icon"><el-icon><Key /></el-icon></div>
<el-descriptions :column="1" border size="small" v-if="otpStatus"> <div><div class="mfa-title-text">{{ $t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' }) }}</div><div class="mfa-title-subtitle">{{ tunnelName(otpUser?.tunnel_id) }}</div></div>
<el-descriptions-item :label="$t('vpnUsers.bindStatus')">
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" size="small">
{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.unbound') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.staticPin')">{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.failCount')">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.lockStatus')">
<span v-if="otpStatus.mfa_locked" style="color:#f56c6c">
{{ $t('vpnUsers.lockedUntil', { time: otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString() : '' }) }}
</span>
<span v-else>{{ $t('common.no') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.lastOtpSuccess')">
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}
</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.compatible')">{{ otpStatus.compatible }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px">
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button>
<el-button v-else type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button>
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button>
</div>
<div v-if="bindStep" style="margin-top:16px;text-align:center;border-top:1px solid #ebeef5;padding-top:16px">
<p style="font-size:13px;color:#606266">{{ $t('vpnUsers.scanHint') }}</p>
<canvas ref="mfaCanvas"></canvas>
<div v-if="mfaSecret" style="margin-top:8px;font-size:12px;color:#909399;word-break:break-all">
{{ $t('vpnUsers.base32Key', { key: mfaSecret }) }}
</div> </div>
<el-input v-model="mfaCode" :placeholder="$t('vpnUsers.input6DigitCode')" style="margin-top:12px;max-width:280px" maxlength="8" /> </template>
<div style="margin-top:10px"> <div v-if="otpStatus" class="mfa-content">
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button> <div class="mfa-status-strip" :class="{ enabled: otpStatus.mfa_enabled }">
<el-button @click="bindStep = false">{{ $t('common.cancel') }}</el-button> <el-icon class="mfa-status-icon"><CircleCheckFilled v-if="otpStatus.mfa_enabled" /><Key v-else /></el-icon>
<div class="mfa-status-copy"><strong>{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.mfaNotEnabled') }}</strong><span>{{ otpStatus.mfa_enabled ? $t('vpnUsers.mfaEnabledHint') : $t('vpnUsers.mfaDisabledHint') }}</span></div>
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" effect="plain">{{ otpStatus.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}</el-tag>
</div> </div>
<template v-if="!otpStatus.mfa_enabled">
<div v-if="!bindStep" class="mfa-empty-state"><div class="mfa-empty-icon"><el-icon><Key /></el-icon></div><p>{{ $t('vpnUsers.mfaSetupIntro') }}</p><el-button type="primary" size="large" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button></div>
<div v-else class="mfa-enrollment">
<div class="mfa-qr-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.scanHint') }}</div><div class="mfa-qr"><canvas ref="mfaCanvas"></canvas></div><el-collapse v-if="mfaSecret" v-model="manualSecretOpen" class="mfa-manual-secret"><el-collapse-item :title="$t('vpnUsers.manualSetup')" name="secret"><code>{{ mfaSecret }}</code></el-collapse-item></el-collapse></div>
<div class="mfa-verify-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.verifySetup') }}</div><p>{{ $t('vpnUsers.verifySetupHint') }}</p><el-input v-model="mfaCode" inputmode="numeric" maxlength="6" :placeholder="$t('vpnUsers.input6DigitCode')" /><div class="mfa-action-row"><el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button><el-button @click="cancelMFASetup">{{ $t('common.cancel') }}</el-button></div></div>
</div>
</template>
<template v-else>
<div class="mfa-metrics"><div class="mfa-metric"><span>{{ $t('vpnUsers.staticPin') }}</span><strong>{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lockStatus') }}</span><strong :class="{ danger: otpStatus.mfa_locked }">{{ otpStatus.mfa_locked ? $t('vpnUsers.locked') : $t('common.no') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lastOtpSuccess') }}</span><strong>{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}</strong></div></div>
<div class="mfa-actions"><el-button @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button><el-button @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button><el-button v-if="otpStatus.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button><el-button @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button><el-button type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button></div>
<el-collapse class="mfa-details"><el-collapse-item :title="$t('vpnUsers.securityDetails')" name="details"><div class="mfa-detail-line"><span>{{ $t('vpnUsers.failCount') }}</span><b>{{ otpStatus.mfa_fail_count || 0 }}</b></div><div class="mfa-detail-line"><span>{{ $t('vpnUsers.compatible') }}</span><b>{{ otpStatus.compatible }}</b></div></el-collapse-item></el-collapse>
</template>
</div> </div>
<el-alert v-if="otpStatus?.mfa_enabled" type="success" :closable="false" style="margin-top:16px"
:title="$t('vpnUsers.clientConnectHint')" />
</el-dialog> </el-dialog>
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px"> <el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
@@ -170,8 +157,9 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key, Refresh } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import api from '../api' import api from '../api'
@@ -181,13 +169,19 @@ import { protocolDisplay } from '../protocols'
const { t } = useI18n() const { t } = useI18n()
const users = ref([]) const users = ref([])
const tunnels = ref([]) const tunnels = ref([])
const loading = ref(false)
const keyword = ref('')
const tunnelFilter = ref(null)
const selected = ref([])
const visible = ref(false) const visible = ref(false)
const form = ref({ tunnel_id: null, username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true }) const form = ref({ tunnel_id: null, username: '', display_name: '', password: '', email: '', enabled: true })
// 平台用户选择器 // 平台用户选择器
const pickerVisible = ref(false) const pickerVisible = ref(false)
const pickerLoading = ref(false) const pickerLoading = ref(false)
const platformUsers = ref([]) const platformUsers = ref([])
const platformSelected = ref([])
const importing = ref(false)
const otpVisible = ref(false) const otpVisible = ref(false)
const otpUser = ref(null) const otpUser = ref(null)
@@ -197,6 +191,7 @@ const mfaCode = ref('')
const mfaSecret = ref('') const mfaSecret = ref('')
const mfaCanvas = ref(null) const mfaCanvas = ref(null)
const mfaSaving = ref(false) const mfaSaving = ref(false)
const manualSecretOpen = ref([])
const pinVisible = ref(false) const pinVisible = ref(false)
const pinValue = ref('') const pinValue = ref('')
@@ -207,23 +202,31 @@ const tunnelProto = id => {
} }
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn' const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date() const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
const filteredUsers = computed(() => users.value.filter(user => {
const matchKeyword = !keyword.value || [user.username, user.display_name, user.email].some(v => (v || '').toLowerCase().includes(keyword.value.toLowerCase()))
return matchKeyword && (!tunnelFilter.value || user.tunnel_id === tunnelFilter.value)
}))
async function load() { async function load() {
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')]) loading.value = true
users.value = u.data || [] try {
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol)) const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
users.value = u.data || []
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
} finally { loading.value = false }
} }
function openCreate() { function openCreate() {
form.value = { form.value = {
tunnel_id: tunnels.value[0]?.id || null, tunnel_id: tunnels.value[0]?.id || null,
username: '', display_name: '', source: 'local', password: '', ldap_dn: '', email: '', enabled: true username: '', display_name: '', password: '', email: '', enabled: true
} }
visible.value = true visible.value = true
} }
// 从平台用户选择导入 async function openBulkImport() {
async function openPlatformPicker() { platformSelected.value = []
form.value.tunnel_id = tunnels.value.find(tunnel => tunnel.protocol === 'openvpn')?.id || null
pickerVisible.value = true pickerVisible.value = true
pickerLoading.value = true pickerLoading.value = true
try { try {
@@ -231,14 +234,16 @@ async function openPlatformPicker() {
platformUsers.value = data || [] platformUsers.value = data || []
} catch { platformUsers.value = [] } finally { pickerLoading.value = false } } catch { platformUsers.value = [] } finally { pickerLoading.value = false }
} }
function pickPlatformUser(row) {
form.value.username = row.username async function importPlatformUsers() {
form.value.display_name = row.email ? row.email.split('@')[0] : row.username if (!form.value.tunnel_id || !platformSelected.value.length) return ElMessage.warning(t('vpnUsers.selectTunnelAndUsers'))
form.value.email = row.email || '' importing.value = true
form.value.source = row.source === 'ldap' ? 'ldap' : 'local' try {
form.value.ldap_dn = row.source === 'ldap' ? `uid=${row.username}` : '' const { data } = await api.post('/vpn-users/import-platform', { tunnel_id: form.value.tunnel_id, platform_user_ids: platformSelected.value.map(row => row.id) })
pickerVisible.value = false ElMessage.success(t('vpnUsers.importedCount', { count: data.created || 0 }))
ElMessage.success(t('vpnUsers.userFilled', { username: row.username })) pickerVisible.value = false
await load()
} catch (e) { ElMessage.error(e.response?.data?.error || t('vpnUsers.importFailed')) } finally { importing.value = false }
} }
async function create() { async function create() {
@@ -271,6 +276,16 @@ async function remove(row) {
} }
} }
async function batchRemove() {
try {
await ElMessageBox.confirm(t('vpnUsers.batchDeleteConfirm', { count: selected.value.length }), t('common.confirm'), { type: 'warning' })
await api.post('/vpn-users/batch-delete', { ids: selected.value.map(row => row.id) })
selected.value = []
ElMessage.success(t('vpnUsers.batchDeleteDone'))
await load()
} catch (e) { if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed')) }
}
async function download(row) { async function download(row) {
try { try {
const res = await fetch(`/api/vpn-users/${row.id}/config`, { const res = await fetch(`/api/vpn-users/${row.id}/config`, {
@@ -296,6 +311,7 @@ async function download(row) {
async function openOTPManager(row) { async function openOTPManager(row) {
otpUser.value = row otpUser.value = row
bindStep.value = false bindStep.value = false
manualSecretOpen.value = []
otpVisible.value = true otpVisible.value = true
await refreshOTPStatus() await refreshOTPStatus()
} }
@@ -323,6 +339,7 @@ async function setupMFA() {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`) const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
mfaCode.value = '' mfaCode.value = ''
mfaSecret.value = data.secret || '' mfaSecret.value = data.secret || ''
manualSecretOpen.value = []
bindStep.value = true bindStep.value = true
await nextTick() await nextTick()
if (mfaCanvas.value && data.otpauth_url) { if (mfaCanvas.value && data.otpauth_url) {
@@ -333,6 +350,13 @@ async function setupMFA() {
} }
} }
function cancelMFASetup() {
bindStep.value = false
mfaCode.value = ''
mfaSecret.value = ''
manualSecretOpen.value = []
}
async function enableMFA() { async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode')) if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
mfaSaving.value = true mfaSaving.value = true
@@ -418,3 +442,45 @@ async function exportOtpSecrets() {
onMounted(load) onMounted(load)
</script> </script>
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.vpn-header, .vpn-header-actions, .vpn-toolbar, .vpn-batch-bar { display:flex; align-items:center; gap:8px; }
.vpn-header { justify-content:space-between; }
.vpn-toolbar { padding: 0 0 12px; }
.vpn-batch-bar { justify-content:space-between; margin: 0 0 10px; padding: 8px 10px; background:#fef3c7; color:#92400e; border:1px solid #fde68a; font-size:13px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }
.mfa-title-text { font-size: 16px; font-weight: 650; color: #1f2937; }
.mfa-title-subtitle { margin-top: 2px; color: #6b7280; font-size: 12px; }
.mfa-content { display: grid; gap: 16px; }
.mfa-status-strip { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d1d5db; background: #f9fafb; }
.mfa-status-strip.enabled { border-color: #a7f3d0; background: #ecfdf5; }
.mfa-status-icon { color: #64748b; font-size: 22px; }
.mfa-status-strip.enabled .mfa-status-icon { color: #16a34a; }
.mfa-status-copy { display: grid; gap: 2px; flex: 1; }
.mfa-status-copy strong { color: #1f2937; font-size: 14px; }
.mfa-status-copy span { color: #6b7280; font-size: 12px; }
.mfa-empty-state { display: grid; justify-items: center; gap: 12px; padding: 26px 16px 20px; text-align: center; }
.mfa-empty-icon { width: 48px; height: 48px; border-radius: 50%; font-size: 22px; }
.mfa-empty-state p { margin: 0; color: #4b5563; font-size: 13px; }
.mfa-enrollment { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); border: 1px solid #e5e7eb; }
.mfa-qr-panel, .mfa-verify-panel { padding: 18px; }
.mfa-qr-panel { display: grid; justify-items: center; border-right: 1px solid #e5e7eb; }
.mfa-panel-label { color: #374151; font-weight: 600; font-size: 13px; }
.mfa-qr { margin: 14px 0 8px; padding: 10px; background: #fff; border: 1px solid #e5e7eb; }
.mfa-manual-secret { width: 100%; }
.mfa-manual-secret code { display: block; overflow-wrap: anywhere; color: #334155; font-size: 12px; }
.mfa-verify-panel { display: grid; align-content: center; gap: 12px; }
.mfa-verify-panel p { margin: 0; color: #6b7280; font-size: 12px; line-height: 1.55; }
.mfa-action-row, .mfa-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mfa-metrics { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #e5e7eb; }
.mfa-metric { display: grid; gap: 5px; padding: 13px; border-right: 1px solid #e5e7eb; }
.mfa-metric:last-child { border-right: 0; }
.mfa-metric span { color: #6b7280; font-size: 12px; }
.mfa-metric strong { color: #1f2937; font-size: 13px; word-break: break-word; }
.mfa-metric .danger { color: #dc2626; }
.mfa-details { margin-top: -4px; }
.mfa-detail-line { display: flex; justify-content: space-between; padding: 4px 0; color: #4b5563; font-size: 13px; }
@media (max-width: 640px) { .mfa-enrollment { grid-template-columns: 1fr; } .mfa-qr-panel { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metrics { grid-template-columns: 1fr; } .mfa-metric { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metric:last-child { border-bottom: 0; } }
</style>