feat: implement VPN platform management and real protocol services
This commit is contained in:
commit
58c036c34c
45 files changed
+9244
No files matched your search
+45
@@ -0,0 +1,45 @@
|
||||
# ============================================
|
||||
# VPN 管理平台 忽略配置
|
||||
# ============================================
|
||||
|
||||
# 构建产物
|
||||
opsx-vpn
|
||||
backend/dist/
|
||||
frontend/dist/
|
||||
|
||||
# 依赖
|
||||
frontend/node_modules/
|
||||
|
||||
# 运行时数据
|
||||
data/
|
||||
logs/
|
||||
|
||||
# PID 文件
|
||||
.vpn.pid
|
||||
.vpn.pid.dev
|
||||
.frontend.pid
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
|
||||
# 环境
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,64 @@
|
||||
# VPN 管理平台 - Makefile // Logan.Li
|
||||
# 提供常用开发命令的快捷方式(与 run.sh 对应)
|
||||
|
||||
.PHONY: help build build-frontend build-binary run dev stop restart status clean
|
||||
|
||||
# 默认显示帮助
|
||||
help:
|
||||
@echo "VPN 管理平台 - 可用命令:"
|
||||
@echo ""
|
||||
@echo " make build - 全量构建(前端 + 后端单二进制)"
|
||||
@echo " make build-frontend - 仅构建前端(输出到 backend/dist)"
|
||||
@echo " make build-binary - 仅编译 Go 二进制(需先构建前端)"
|
||||
@echo " make run - 构建并后台运行单二进制"
|
||||
@echo " make dev - 开发模式(前后端分离)"
|
||||
@echo " make stop - 停止所有服务"
|
||||
@echo " make restart - 重启服务"
|
||||
@echo " make status - 查看服务状态"
|
||||
@echo " make clean - 清理构建产物"
|
||||
@echo ""
|
||||
|
||||
# 构建前端
|
||||
build-frontend:
|
||||
@echo "🔨 构建前端..."
|
||||
cd frontend && npm install && npm run build
|
||||
|
||||
# 编译 Go 二进制
|
||||
build-binary:
|
||||
@echo "🔨 编译 Go 单二进制..."
|
||||
@if [ ! -d backend/dist ]; then mkdir -p backend/dist && echo '<!doctype html><html><body>请先构建前端</body></html>' > backend/dist/index.html; fi
|
||||
go build -ldflags="-s -w" -o opsx-vpn ./backend
|
||||
|
||||
# 全量构建
|
||||
build: build-frontend build-binary
|
||||
@echo "✅ 全量构建完成: opsx-vpn"
|
||||
|
||||
# 构建并运行
|
||||
run:
|
||||
./run.sh run
|
||||
|
||||
# 开发模式
|
||||
dev:
|
||||
./run.sh dev
|
||||
|
||||
# 停止服务
|
||||
stop:
|
||||
./run.sh stop
|
||||
|
||||
# 重启服务
|
||||
restart:
|
||||
./run.sh restart
|
||||
|
||||
# 查看状态
|
||||
status:
|
||||
./run.sh status
|
||||
|
||||
# 清理
|
||||
clean:
|
||||
@echo "🧹 清理构建产物..."
|
||||
rm -f opsx-vpn
|
||||
rm -rf backend/dist
|
||||
rm -rf frontend/node_modules
|
||||
rm -rf frontend/dist
|
||||
rm -f logs/*.log
|
||||
@echo "🧹 清理完成"
|
||||
@@ -0,0 +1,110 @@
|
||||
# VPN 专线管理平台
|
||||
|
||||
Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交付、配置简单、管理便捷、可扩展自定义协议**。
|
||||
|
||||
## 支持协议
|
||||
|
||||
| 协议 | 实现方式 | 场景 |
|
||||
|---|---|---|
|
||||
| WireGuard | 系统 `wg` / `wg-quick`,密钥自动生成 | 内部接入、移动办公(首选) |
|
||||
| IPSec | StrongSwan `swanctl`,内置 Cisco/飞塔/华为/华三模板 | **第三方对接**、站点互联 |
|
||||
| L2TP/IPSec | StrongSwan + xl2tpd | 手机/老设备兼容 |
|
||||
| FRP | 直接管理 `frps` / `frpc`(TOML) | 内网穿透、端口映射 |
|
||||
| OpenVPN | 进程管理 + 配置模板 + PKI 脚本 | 老项目兼容 |
|
||||
| 自定义协议 | 插件命令钩子(Create/Start/Stop/Status/Config) | 任意协议扩展 |
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **专线列表**:名称/协议/状态/流量/到期时间,一键启停、编辑、删除、下载配置、WireGuard 二维码
|
||||
- **创建向导**:选协议 → 填参数 → 自动生成服务端与客户端配置
|
||||
- **IPSec 第三方对接专页**:设备模板预置 Phase1/Phase2 参数、双方参数一致性检查、一键连通性测试、对端参考配置导出
|
||||
- **节点管理**:多中继节点(本机内置,远程节点预留)
|
||||
- **协议插件**:注册命令钩子即接入新协议,钩子通过 stdin 接收专线配置 JSON
|
||||
- **安全**:PSK/私钥 AES-GCM 加密存储、配置文件 0600 权限、操作审计
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 单二进制构建与运行(默认,不使用 Docker)
|
||||
|
||||
```bash
|
||||
# 方式一:一键构建并运行
|
||||
./run.sh run
|
||||
|
||||
# 方式二:make
|
||||
make run
|
||||
```
|
||||
|
||||
`run.sh` 会自动完成「构建前端 → 编译 Go → 启动单二进制」全流程,监听 `:8080`,默认账号 **admin / admin123**。
|
||||
|
||||
### 分步构建
|
||||
|
||||
```bash
|
||||
./run.sh build # 全量构建(前端 + 二进制)
|
||||
./run.sh build-frontend # 仅构建前端
|
||||
./run.sh build-binary # 仅编译二进制(需先构建前端)
|
||||
./opsx-vpn # 直接运行二进制
|
||||
```
|
||||
|
||||
### 开发模式(前后端分离)
|
||||
|
||||
```bash
|
||||
./run.sh dev
|
||||
# 前端 http://localhost:5173(vite dev,代理 /api 到后端)
|
||||
# 后端 http://localhost:8080(go run)
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
| `./run.sh run` | 构建并后台运行单二进制(默认) |
|
||||
| `./run.sh build` | 全量构建(前端 + 后端二进制) |
|
||||
| `./run.sh dev` | 开发模式(前后端分离) |
|
||||
| `./run.sh stop` | 停止所有服务 |
|
||||
| `./run.sh restart` | 重启服务 |
|
||||
| `./run.sh status` | 查看服务状态 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `VPN_LISTEN_ADDR` | `:8080` | 监听地址 |
|
||||
| `VPN_DATA_DIR` | `./data` | 数据目录(SQLite、密钥、实例配置) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
frontend/ # Vue3 + Element Plus 前端
|
||||
backend/
|
||||
main.go # Gin 入口(内嵌前端 embed.FS)
|
||||
internal/
|
||||
api/ # REST 路由与鉴权
|
||||
core/ # Protocol 统一接口 + 注册中心
|
||||
model/ # GORM 数据模型
|
||||
store/ # SQLite + AES-GCM 密钥存储
|
||||
service/ # 协议实现
|
||||
wireguard/ ipsec/ l2tp/ frp/ openvpn/
|
||||
custom/ # 自定义协议插件
|
||||
monitor/ # 状态/流量采集、到期自动停止
|
||||
```
|
||||
|
||||
## 自定义协议插件契约
|
||||
|
||||
在「协议插件」页注册 5 个命令钩子,平台执行时将 `{"id","name","protocol","config"}` JSON 写入 stdin:
|
||||
|
||||
| 钩子 | 约定 |
|
||||
|---|---|
|
||||
| Start(必填) | 启动实例,失败退出码非 0 |
|
||||
| Stop | 停止实例 |
|
||||
| Status | stdout 输出 `running` / `stopped` |
|
||||
| Config | stdout 输出客户端配置文本(供下载) |
|
||||
| Create(可选) | 创建时调用,用于渲染配置 |
|
||||
|
||||
## 依赖检测与部署状态
|
||||
|
||||
平台启动时自动检测 `wg / swanctl / xl2tpd / openvpn / frps / frpc`。依赖缺失时仍可创建和导出配置,但专线会标记为**未部署**,启动操作将明确报错;安装对应依赖后即可由平台启动和管理真实进程。
|
||||
|
||||
## 交付形态
|
||||
|
||||
- **单二进制**:前端构建后通过 `go:embed` 内嵌进 Go 二进制,`./opsx-vpn` 单文件即可运行(含 SPA + REST API + SQLite)
|
||||
- 不依赖 Docker、不依赖外部数据库
|
||||
@@ -0,0 +1,781 @@
|
||||
// Package api 提供 REST API 路由与处理器
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/service/custom"
|
||||
"vpn-platform/backend/internal/service/ipsec"
|
||||
"vpn-platform/backend/internal/service/openvpn"
|
||||
"vpn-platform/backend/internal/service/wireguard"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// Server API 服务器
|
||||
type Server struct {
|
||||
mgr *core.Manager
|
||||
}
|
||||
|
||||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr} }
|
||||
|
||||
// ---- 简易 Token 会话 ----
|
||||
var (
|
||||
sessions = map[string]sessionInfo{}
|
||||
sessionsMu sync.RWMutex
|
||||
)
|
||||
|
||||
type sessionInfo struct {
|
||||
username string
|
||||
userID uint
|
||||
role string
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
func issueToken(user *model.User) string {
|
||||
raw := make([]byte, 32)
|
||||
_, _ = rand.Read(raw)
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sessionsMu.Lock()
|
||||
sessions[token] = sessionInfo{username: user.Username, userID: user.ID, role: user.Role, expireAt: time.Now().Add(24 * time.Hour)}
|
||||
sessionsMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
func authMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("Authorization")
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
sessionsMu.RLock()
|
||||
sess, ok := sessions[token]
|
||||
sessionsMu.RUnlock()
|
||||
if !ok || time.Now().After(sess.expireAt) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
c.Set("username", sess.username)
|
||||
c.Set("user_id", sess.userID)
|
||||
c.Set("role", sess.role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册全部路由
|
||||
func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
api := r.Group("/api")
|
||||
api.POST("/login", s.login)
|
||||
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
auth := api.Group("")
|
||||
auth.Use(authMiddleware())
|
||||
{
|
||||
auth.GET("/overview", s.overview)
|
||||
auth.GET("/protocols", s.listProtocols)
|
||||
auth.POST("/password", s.changePassword)
|
||||
auth.GET("/mfa/status", s.mfaStatus)
|
||||
auth.POST("/mfa/setup", s.mfaSetup)
|
||||
auth.POST("/mfa/enable", s.mfaEnable)
|
||||
auth.POST("/mfa/disable", s.mfaDisable)
|
||||
|
||||
admin := auth.Group("")
|
||||
admin.Use(requireRole("admin"))
|
||||
admin.GET("/users", s.listUsers)
|
||||
admin.POST("/users", s.createUser)
|
||||
admin.PUT("/users/:id", s.updateUser)
|
||||
admin.DELETE("/users/:id", s.deleteUser)
|
||||
admin.GET("/ldap", s.getLDAPConfig)
|
||||
admin.PUT("/ldap", s.saveLDAPConfig)
|
||||
admin.POST("/ldap/test", s.testLDAP)
|
||||
admin.GET("/vpn-users", s.listVPNUsers)
|
||||
admin.POST("/vpn-users", s.createVPNUser)
|
||||
admin.PUT("/vpn-users/:id", s.updateVPNUser)
|
||||
admin.DELETE("/vpn-users/:id", s.deleteVPNUser)
|
||||
admin.POST("/tunnels/:id/openvpn/pki", s.initializeOpenVPNPKI)
|
||||
admin.GET("/vpn-users/:id/config", s.downloadVPNUserConfig)
|
||||
admin.GET("/tunnels/:id/wireguard/peers/:peer_id/config", s.downloadWireGuardPeerConfig)
|
||||
|
||||
auth.GET("/tunnels", s.listTunnels)
|
||||
auth.POST("/tunnels", s.createTunnel)
|
||||
auth.GET("/tunnels/:id", s.getTunnel)
|
||||
auth.PUT("/tunnels/:id", s.updateTunnel)
|
||||
auth.DELETE("/tunnels/:id", s.deleteTunnel)
|
||||
auth.POST("/tunnels/:id/start", s.startTunnel)
|
||||
auth.POST("/tunnels/:id/stop", s.stopTunnel)
|
||||
auth.GET("/tunnels/:id/config", s.downloadConfig)
|
||||
auth.GET("/tunnels/:id/status", s.tunnelStatus)
|
||||
auth.POST("/tunnels/:id/test", s.testConnectivity)
|
||||
|
||||
auth.GET("/ipsec/templates", s.ipsecTemplates)
|
||||
auth.POST("/ipsec/compare", s.ipsecCompare)
|
||||
|
||||
auth.GET("/nodes", s.listNodes)
|
||||
auth.POST("/nodes", s.createNode)
|
||||
auth.DELETE("/nodes/:id", s.deleteNode)
|
||||
|
||||
auth.GET("/plugins", s.listPlugins)
|
||||
auth.POST("/plugins", s.createPlugin)
|
||||
auth.PUT("/plugins/:id", s.updatePlugin)
|
||||
auth.DELETE("/plugins/:id", s.deletePlugin)
|
||||
|
||||
auth.GET("/audit", s.listAudit)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) initializeOpenVPNPKI(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if t.Protocol != "openvpn" {
|
||||
c.JSON(400, gin.H{"error": "仅 OpenVPN 隧道支持 PKI 初始化"})
|
||||
return
|
||||
}
|
||||
if err := openvpn.New().InitializePKI(t); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "initialize_openvpn_pki", t.Name, "")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) downloadVPNUserConfig(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "非法 ID"})
|
||||
return
|
||||
}
|
||||
var user model.VPNUser
|
||||
if err := store.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
t, err := store.GetTunnel(user.TunnelID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "OpenVPN 隧道不存在"})
|
||||
return
|
||||
}
|
||||
if t.Protocol != "openvpn" {
|
||||
c.JSON(400, gin.H{"error": "拨号用户不属于 OpenVPN 隧道"})
|
||||
return
|
||||
}
|
||||
conf, err := openvpn.New().GenerateUserConfig(t, &user)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=openvpn-%s.ovpn", user.Username))
|
||||
c.Data(200, "application/x-openvpn-profile", []byte(conf))
|
||||
}
|
||||
|
||||
func (s *Server) downloadWireGuardPeerConfig(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if t.Protocol != "wireguard" {
|
||||
c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 配置"})
|
||||
return
|
||||
}
|
||||
conf, err := wireguard.New().GeneratePeerClientConfig(t, c.Param("peer_id"))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=wireguard-%s-%s.conf", t.Name, c.Param("peer_id")))
|
||||
c.Data(200, "text/plain; charset=utf-8", []byte(conf))
|
||||
}
|
||||
|
||||
// ---- 认证 ----
|
||||
|
||||
func (s *Server) login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MFACode string `json:"mfa_code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
user, err := s.authenticateUser(req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
if !user.Enabled {
|
||||
c.JSON(403, gin.H{"error": "用户已禁用"})
|
||||
return
|
||||
}
|
||||
if err := verifyMFA(user, req.MFACode); err != nil {
|
||||
c.JSON(401, gin.H{"error": err.Error(), "mfa_required": true})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"token": issueToken(user), "username": user.Username, "role": user.Role})
|
||||
}
|
||||
|
||||
func (s *Server) changePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Old string `json:"old_password"`
|
||||
New string `json:"new_password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.New) < 6 {
|
||||
c.JSON(400, gin.H{"error": "新密码至少 6 位"})
|
||||
return
|
||||
}
|
||||
username := c.GetString("username")
|
||||
var user model.User
|
||||
if err := store.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Old)); err != nil {
|
||||
c.JSON(400, gin.H{"error": "原密码错误"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.New), bcrypt.DefaultCost)
|
||||
store.DB.Model(&user).Update("password", string(hash))
|
||||
store.Audit(username, "change_password", username, "修改登录密码")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 总览 ----
|
||||
|
||||
func (s *Server) overview(c *gin.Context) {
|
||||
var total, running int64
|
||||
store.DB.Model(&model.Tunnel{}).Count(&total)
|
||||
store.DB.Model(&model.Tunnel{}).Where("state = ?", "running").Count(&running)
|
||||
var nodeCount, pluginCount int64
|
||||
store.DB.Model(&model.Node{}).Count(&nodeCount)
|
||||
store.DB.Model(&model.Plugin{}).Where("enabled = ?", true).Count(&pluginCount)
|
||||
|
||||
// 各协议数量分布
|
||||
type protoCount struct {
|
||||
Protocol string
|
||||
Count int64
|
||||
}
|
||||
var dist []protoCount
|
||||
store.DB.Model(&model.Tunnel{}).Select("protocol, count(*) as count").Group("protocol").Scan(&dist)
|
||||
|
||||
// 总流量
|
||||
var sum struct {
|
||||
Rx int64
|
||||
Tx int64
|
||||
}
|
||||
store.DB.Model(&model.Tunnel{}).Select("COALESCE(SUM(rx_bytes),0) as rx, COALESCE(SUM(tx_bytes),0) as tx").Scan(&sum)
|
||||
|
||||
// 即将到期(7 天内)
|
||||
var expiring []model.Tunnel
|
||||
week := time.Now().Add(7 * 24 * time.Hour)
|
||||
store.DB.Where("expire_at IS NOT NULL AND expire_at < ?", week).Order("expire_at asc").Limit(5).Find(&expiring)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"total": total, "running": running, "nodes": nodeCount, "plugins": pluginCount,
|
||||
"rx_bytes": sum.Rx, "tx_bytes": sum.Tx,
|
||||
"protocol_distribution": dist, "expiring": expiring,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) listProtocols(c *gin.Context) {
|
||||
type item struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Available bool `json:"available"`
|
||||
Dependency string `json:"dependency"`
|
||||
Custom bool `json:"custom"`
|
||||
}
|
||||
var out []item
|
||||
for _, p := range s.mgr.List() {
|
||||
ok, dep := p.Available()
|
||||
out = append(out, item{p.Name(), p.DisplayName(), ok, dep, strings.HasPrefix(p.Name(), "custom:")})
|
||||
}
|
||||
c.JSON(200, out)
|
||||
}
|
||||
|
||||
// ---- 专线管理 ----
|
||||
|
||||
func (s *Server) listTunnels(c *gin.Context) {
|
||||
var tunnels []model.Tunnel
|
||||
q := store.DB.Preload("Node").Order("id desc")
|
||||
if p := c.Query("protocol"); p != "" {
|
||||
q = q.Where("protocol = ?", p)
|
||||
}
|
||||
if kw := c.Query("keyword"); kw != "" {
|
||||
q = q.Where("name LIKE ?", "%"+kw+"%")
|
||||
}
|
||||
q.Find(&tunnels)
|
||||
// 附带实时状态
|
||||
type resp struct {
|
||||
model.Tunnel
|
||||
Runtime *core.Status `json:"runtime,omitempty"`
|
||||
}
|
||||
out := make([]resp, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
r := resp{Tunnel: t}
|
||||
if st, ok := s.mgr.GetStatus(t.ID); ok {
|
||||
r.Runtime = &st
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
c.JSON(200, out)
|
||||
}
|
||||
|
||||
type tunnelReq struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Remark string `json:"remark"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
ExpireAt *time.Time `json:"expire_at"`
|
||||
NodeID *uint `json:"node_id"`
|
||||
}
|
||||
|
||||
func (s *Server) createTunnel(c *gin.Context) {
|
||||
var req tunnelReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Protocol == "" {
|
||||
c.JSON(400, gin.H{"error": "名称与协议类型必填"})
|
||||
return
|
||||
}
|
||||
p, err := s.mgr.Get(req.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg, err := p.Validate(req.Config)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t := model.Tunnel{
|
||||
Name: req.Name, Protocol: req.Protocol, Remark: req.Remark,
|
||||
State: "stopped", AutoStart: req.AutoStart, ExpireAt: req.ExpireAt, NodeID: req.NodeID,
|
||||
}
|
||||
// 先创建不含配置的记录以取得 ID;敏感字段只在内存中交给协议层,
|
||||
// 由 SaveTunnelConfig 拆分并加密后才会写入数据库。
|
||||
t.Config = "{}"
|
||||
if err := store.DB.Create(&t).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
if err := p.Create(&t); err != nil {
|
||||
store.DB.Delete(&t)
|
||||
_ = service.RemoveInstance(&t)
|
||||
c.JSON(500, gin.H{"error": "生成配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
username := c.GetString("username")
|
||||
store.Audit(username, "create_tunnel", fmt.Sprintf("%s(%s)", t.Name, t.Protocol), "")
|
||||
if t.AutoStart {
|
||||
if err := p.Start(&t); err != nil {
|
||||
t.LastError = err.Error()
|
||||
t.State = "error"
|
||||
} else {
|
||||
t.State = "running"
|
||||
}
|
||||
store.DB.Save(&t)
|
||||
}
|
||||
c.JSON(200, t)
|
||||
}
|
||||
|
||||
func (s *Server) getTunnel(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 客户端配置预览
|
||||
var clientConf string
|
||||
if p, err := s.mgr.Get(t.Protocol); err == nil {
|
||||
clientConf, _ = p.GenerateClientConfig(t)
|
||||
}
|
||||
c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf})
|
||||
}
|
||||
|
||||
func (s *Server) updateTunnel(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req tunnelReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
var (
|
||||
p core.Protocol
|
||||
newRaw string
|
||||
hasCfg = req.Config != nil
|
||||
wasRunning = t.State == "running"
|
||||
)
|
||||
if hasCfg {
|
||||
var err error
|
||||
p, err = s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg, err := p.Validate(req.Config)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if hasCfg {
|
||||
t.Config = newRaw
|
||||
if err := p.Create(t); err != nil {
|
||||
c.JSON(500, gin.H{"error": "生成配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if wasRunning {
|
||||
if err := p.Start(t); err != nil {
|
||||
t.State = "error"
|
||||
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})
|
||||
}
|
||||
}
|
||||
store.Audit(c.GetString("username"), "update_tunnel", t.Name, "")
|
||||
c.JSON(200, t)
|
||||
}
|
||||
|
||||
func (s *Server) deleteTunnel(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if p, err := s.mgr.Get(t.Protocol); err == nil {
|
||||
_ = p.Delete(t)
|
||||
}
|
||||
if err := service.RemoveInstance(t); err != nil && !os.IsNotExist(err) {
|
||||
c.JSON(500, gin.H{"error": "清理实例文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
s.mgr.RemoveStatus(t.ID)
|
||||
store.DB.Delete(t)
|
||||
store.Audit(c.GetString("username"), "delete_tunnel", t.Name, "")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) startTunnel(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) {
|
||||
c.JSON(400, gin.H{"error": "专线已到期,请先调整到期时间"})
|
||||
return
|
||||
}
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if t.State == "running" {
|
||||
c.JSON(200, gin.H{"ok": true, "state": "running", "message": "专线已在运行"})
|
||||
return
|
||||
}
|
||||
if err := p.Start(t); err != nil {
|
||||
t.State = "error"
|
||||
t.LastError = err.Error()
|
||||
store.DB.Save(t)
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t.State = "running"
|
||||
t.LastError = ""
|
||||
store.DB.Save(t)
|
||||
store.Audit(c.GetString("username"), "start_tunnel", t.Name, "")
|
||||
c.JSON(200, gin.H{"ok": true, "state": "running"})
|
||||
}
|
||||
|
||||
func (s *Server) stopTunnel(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := p.Stop(t); err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t.State = "stopped"
|
||||
store.DB.Save(t)
|
||||
store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "")
|
||||
c.JSON(200, gin.H{"ok": true, "state": "stopped"})
|
||||
}
|
||||
|
||||
func (s *Server) downloadConfig(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
conf, err := p.GenerateClientConfig(t)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ext := "conf"
|
||||
switch t.Protocol {
|
||||
case "openvpn":
|
||||
ext = "ovpn"
|
||||
case "frp":
|
||||
ext = "toml"
|
||||
case "ipsec", "l2tp":
|
||||
ext = "txt"
|
||||
}
|
||||
filename := fmt.Sprintf("%s-%s.%s", t.Protocol, strings.ReplaceAll(t.Name, " ", "_"), ext)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(200, "text/plain; charset=utf-8", []byte(conf))
|
||||
}
|
||||
|
||||
func (s *Server) tunnelStatus(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p, err := s.mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := p.Status(t)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, st)
|
||||
}
|
||||
|
||||
// testConnectivity 一键测试与对端网关的连通性
|
||||
func (s *Server) testConnectivity(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target := service.Str(cfg, "remote_gateway")
|
||||
if target == "" {
|
||||
target = service.Str(cfg, "server_addr")
|
||||
}
|
||||
if target == "" {
|
||||
target = service.Str(cfg, "public_endpoint")
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(target); err == nil {
|
||||
target = host
|
||||
}
|
||||
if target == "" {
|
||||
c.JSON(400, gin.H{"error": "该专线未配置对端地址,无法测试"})
|
||||
return
|
||||
}
|
||||
result := map[string]interface{}{"target": target}
|
||||
start := time.Now()
|
||||
// ICMP 不可用时退化为 TCP 常见端口探测
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(target, "500"), 3*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
result["ike_udp500_note"] = "TCP 探测通过(仅供参考,IPSec 实际走 UDP 500/4500)"
|
||||
result["reachable"] = true
|
||||
} else {
|
||||
conn2, err2 := net.DialTimeout("tcp", net.JoinHostPort(target, "443"), 3*time.Second)
|
||||
if err2 == nil {
|
||||
conn2.Close()
|
||||
result["reachable"] = true
|
||||
result["note"] = "对端可达(TCP 443)"
|
||||
} else {
|
||||
result["reachable"] = false
|
||||
result["note"] = "TCP 探测未通过,可能防火墙仅放行 UDP,建议结合 IPSec SA 状态判断"
|
||||
}
|
||||
}
|
||||
result["latency_ms"] = time.Since(start).Milliseconds()
|
||||
store.Audit(c.GetString("username"), "test_connectivity", t.Name, fmt.Sprintf("target=%s", target))
|
||||
c.JSON(200, result)
|
||||
}
|
||||
|
||||
// ---- IPSec 专用 ----
|
||||
|
||||
func (s *Server) ipsecTemplates(c *gin.Context) {
|
||||
c.JSON(200, ipsec.Templates())
|
||||
}
|
||||
|
||||
// ipsecCompare 双方参数一致性对比检查
|
||||
func (s *Server) ipsecCompare(c *gin.Context) {
|
||||
var req struct {
|
||||
Local map[string]interface{} `json:"local"`
|
||||
Peer map[string]interface{} `json:"peer"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
diffs := ipsec.ComparePeerParams(req.Local, req.Peer)
|
||||
c.JSON(200, gin.H{"consistent": len(diffs) == 0, "differences": diffs})
|
||||
}
|
||||
|
||||
// ---- 节点管理 ----
|
||||
|
||||
func (s *Server) listNodes(c *gin.Context) {
|
||||
var nodes []model.Node
|
||||
store.DB.Find(&nodes)
|
||||
c.JSON(200, nodes)
|
||||
}
|
||||
|
||||
func (s *Server) createNode(c *gin.Context) {
|
||||
var n model.Node
|
||||
if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" {
|
||||
c.JSON(400, gin.H{"error": "节点名称必填"})
|
||||
return
|
||||
}
|
||||
n.ID = 0
|
||||
store.DB.Create(&n)
|
||||
store.Audit(c.GetString("username"), "create_node", n.Name, "")
|
||||
c.JSON(200, n)
|
||||
}
|
||||
|
||||
func (s *Server) deleteNode(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
store.DB.Delete(&model.Node{}, id)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 自定义协议插件 ----
|
||||
|
||||
func (s *Server) listPlugins(c *gin.Context) {
|
||||
var plugins []model.Plugin
|
||||
store.DB.Find(&plugins)
|
||||
c.JSON(200, plugins)
|
||||
}
|
||||
|
||||
func (s *Server) createPlugin(c *gin.Context) {
|
||||
var p model.Plugin
|
||||
if err := c.ShouldBindJSON(&p); err != nil || p.Name == "" || p.StartHook == "" {
|
||||
c.JSON(400, gin.H{"error": "插件标识与 start 钩子必填"})
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(p.Name, " /;|&") {
|
||||
c.JSON(400, gin.H{"error": "插件标识不能包含特殊字符"})
|
||||
return
|
||||
}
|
||||
p.ID = 0
|
||||
if p.DisplayName == "" {
|
||||
p.DisplayName = p.Name
|
||||
}
|
||||
if err := store.DB.Create(&p).Error; err != nil {
|
||||
c.JSON(400, gin.H{"error": "插件标识已存在"})
|
||||
return
|
||||
}
|
||||
if p.Enabled {
|
||||
s.mgr.Register(custom.New(&p))
|
||||
}
|
||||
store.Audit(c.GetString("username"), "create_plugin", p.Name, "")
|
||||
c.JSON(200, p)
|
||||
}
|
||||
|
||||
func (s *Server) updatePlugin(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var p model.Plugin
|
||||
if err := store.DB.First(&p, id).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "插件不存在"})
|
||||
return
|
||||
}
|
||||
var req model.Plugin
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
p.DisplayName = req.DisplayName
|
||||
p.Description = req.Description
|
||||
p.CreateHook, p.StartHook, p.StopHook = req.CreateHook, req.StartHook, req.StopHook
|
||||
p.StatusHook, p.ConfigHook = req.StatusHook, req.ConfigHook
|
||||
p.Enabled = req.Enabled
|
||||
store.DB.Save(&p)
|
||||
if p.Enabled {
|
||||
s.mgr.Register(custom.New(&p))
|
||||
}
|
||||
c.JSON(200, p)
|
||||
}
|
||||
|
||||
func (s *Server) deletePlugin(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
store.DB.Delete(&model.Plugin{}, id)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 审计 ----
|
||||
|
||||
func (s *Server) listAudit(c *gin.Context) {
|
||||
var logs []model.AuditLog
|
||||
store.DB.Order("id desc").Limit(100).Find(&logs)
|
||||
c.JSON(200, logs)
|
||||
}
|
||||
|
||||
// ---- 工具 ----
|
||||
|
||||
func (s *Server) mustTunnel(c *gin.Context) (*model.Tunnel, error) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "非法 ID"})
|
||||
return nil, err
|
||||
}
|
||||
t, err := store.GetTunnel(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "专线不存在"})
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type pendingMFA struct {
|
||||
secret string
|
||||
backups []string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
pendingMFAMu sync.Mutex
|
||||
pendingMFAs = map[uint]pendingMFA{}
|
||||
)
|
||||
|
||||
func requireRole(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
for _, allowed := range roles {
|
||||
if role == allowed {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticateUser(username, password string) (*model.User, error) {
|
||||
var user model.User
|
||||
err := store.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil && user.Source != "ldap" {
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil {
|
||||
return &user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid password")
|
||||
}
|
||||
if user.Source == "ldap" || err != nil {
|
||||
return s.authenticateLDAP(username, password)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func ldapSettings() (*model.LDAPConfig, error) {
|
||||
var cfg model.LDAPConfig
|
||||
if err := store.DB.First(&cfg).Error; err != nil || !cfg.Enabled {
|
||||
return nil, fmt.Errorf("LDAP 未启用")
|
||||
}
|
||||
plain, err := store.DecryptSecret(cfg.BindPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LDAP 配置密钥无效")
|
||||
}
|
||||
cfg.BindPassword = plain
|
||||
if cfg.UsernameAttribute == "" {
|
||||
cfg.UsernameAttribute = "uid"
|
||||
}
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=inetOrgPerson)"
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
||||
url := cfg.URL
|
||||
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
|
||||
url = "ldap://" + url
|
||||
}
|
||||
conn, err := ldap.DialURL(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (s *Server) authenticateLDAP(username, password string) (*model.User, error) {
|
||||
cfg, err := ldapSettings()
|
||||
if err != nil || password == "" {
|
||||
return nil, fmt.Errorf("LDAP 认证不可用")
|
||||
}
|
||||
conn, err := ldapConnect(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
|
||||
result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn", cfg.UsernameAttribute, "cn", "mail"}, nil))
|
||||
if err != nil || len(result.Entries) != 1 {
|
||||
return nil, fmt.Errorf("LDAP 用户不存在")
|
||||
}
|
||||
entry := result.Entries[0]
|
||||
if err := conn.Bind(entry.DN, password); err != nil {
|
||||
return nil, fmt.Errorf("LDAP 密码错误")
|
||||
}
|
||||
var user model.User
|
||||
err = store.DB.Where("username = ?", username).First(&user).Error
|
||||
if err != nil {
|
||||
user = model.User{Username: username, Email: entry.GetAttributeValue("mail"), Source: "ldap", Role: "viewer", Enabled: true}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func verifyMFA(user *model.User, code string) error {
|
||||
var device model.MFADevice
|
||||
if err := store.DB.Where("user_id = ? AND enabled = ?", user.ID, true).First(&device).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
secret, err := store.DecryptSecret(device.Secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MFA 配置无效")
|
||||
}
|
||||
if totp.Validate(strings.TrimSpace(code), secret) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("需要有效的 MFA 验证码")
|
||||
}
|
||||
|
||||
func (s *Server) mfaStatus(c *gin.Context) {
|
||||
var device model.MFADevice
|
||||
err := store.DB.Where("user_id = ?", c.GetUint("user_id")).First(&device).Error
|
||||
c.JSON(200, gin.H{"enabled": err == nil && device.Enabled})
|
||||
}
|
||||
|
||||
func backupCodes() []string {
|
||||
codes := make([]string, 10)
|
||||
for i := range codes {
|
||||
raw := make([]byte, 5)
|
||||
_, _ = rand.Read(raw)
|
||||
codes[i] = fmt.Sprintf("%X", raw)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func (s *Server) mfaSetup(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := store.DB.First(&user, c.GetUint("user_id")).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: "OPSX VPN", AccountName: user.Username})
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pendingMFAMu.Lock()
|
||||
pendingMFAs[user.ID] = pendingMFA{secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute)}
|
||||
pendingMFAMu.Unlock()
|
||||
c.JSON(200, gin.H{"otpauth_url": key.URL(), "secret": key.Secret(), "expires_in": 900})
|
||||
}
|
||||
|
||||
func (s *Server) mfaEnable(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
userID := c.GetUint("user_id")
|
||||
pendingMFAMu.Lock()
|
||||
pending, ok := pendingMFAs[userID]
|
||||
delete(pendingMFAs, userID)
|
||||
pendingMFAMu.Unlock()
|
||||
if !ok || time.Now().After(pending.expires) || !totp.Validate(req.Code, pending.secret) {
|
||||
c.JSON(400, gin.H{"error": "验证码无效或设置已过期"})
|
||||
return
|
||||
}
|
||||
secret, _ := store.EncryptSecret(pending.secret)
|
||||
raw, _ := json.Marshal(pending.backups)
|
||||
backups, _ := store.EncryptSecret(string(raw))
|
||||
device := model.MFADevice{UserID: userID, Secret: secret, BackupCodes: backups, Enabled: true}
|
||||
store.DB.Where("user_id = ?", userID).Assign(device).FirstOrCreate(&device)
|
||||
store.Audit(c.GetString("username"), "enable_mfa", c.GetString("username"), "")
|
||||
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups})
|
||||
}
|
||||
|
||||
func (s *Server) mfaDisable(c *gin.Context) {
|
||||
store.DB.Where("user_id = ?", c.GetUint("user_id")).Delete(&model.MFADevice{})
|
||||
store.Audit(c.GetString("username"), "disable_mfa", c.GetString("username"), "")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(c *gin.Context) {
|
||||
var users []model.User
|
||||
store.DB.Order("id").Find(&users)
|
||||
c.JSON(200, users)
|
||||
}
|
||||
|
||||
func (s *Server) createUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Username, Password, Email, Role string
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.Username == "" || len(req.Password) < 8 {
|
||||
c.JSON(400, gin.H{"error": "用户名及至少 8 位的密码必填"})
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = "viewer"
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "operator" && req.Role != "viewer" {
|
||||
c.JSON(400, gin.H{"error": "角色无效"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user := model.User{Username: req.Username, Password: string(hash), Email: req.Email, Role: req.Role, Source: "local", Enabled: req.Enabled}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(400, gin.H{"error": "用户创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "create_user", user.Username, "")
|
||||
c.JSON(200, user)
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.User
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email, Role, Password string
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Role != "" {
|
||||
user.Role = req.Role
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(400, gin.H{"error": "密码至少 8 位"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
}
|
||||
store.DB.Save(&user)
|
||||
c.JSON(200, user)
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if uint(id) == c.GetUint("user_id") {
|
||||
c.JSON(400, gin.H{"error": "不能删除当前用户"})
|
||||
return
|
||||
}
|
||||
store.DB.Delete(&model.User{}, id)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) getLDAPConfig(c *gin.Context) {
|
||||
var cfg model.LDAPConfig
|
||||
if store.DB.First(&cfg).Error != nil {
|
||||
c.JSON(200, gin.H{"enabled": false})
|
||||
return
|
||||
}
|
||||
c.JSON(200, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) saveLDAPConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
BindDN string `json:"bind_dn"`
|
||||
BindPassword string `json:"bind_password"`
|
||||
BaseDN string `json:"base_dn"`
|
||||
UserFilter string `json:"user_filter"`
|
||||
UsernameAttribute string `json:"username_attribute"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || (req.Enabled && (req.URL == "" || req.BaseDN == "")) {
|
||||
c.JSON(400, gin.H{"error": "LDAP 地址与 Base DN 必填"})
|
||||
return
|
||||
}
|
||||
var cfg model.LDAPConfig
|
||||
store.DB.First(&cfg)
|
||||
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.Enabled = req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.Enabled
|
||||
if req.BindPassword != "" {
|
||||
cfg.BindPassword, _ = store.EncryptSecret(req.BindPassword)
|
||||
}
|
||||
store.DB.Save(&cfg)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) testLDAP(c *gin.Context) {
|
||||
cfg, err := ldapSettings()
|
||||
if err == nil {
|
||||
conn, connErr := ldapConnect(cfg)
|
||||
if connErr == nil {
|
||||
conn.Close()
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
err = connErr
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "LDAP 连接失败: " + err.Error()})
|
||||
}
|
||||
|
||||
func (s *Server) listVPNUsers(c *gin.Context) {
|
||||
var users []model.VPNUser
|
||||
q := store.DB.Order("id desc")
|
||||
if id := c.Query("tunnel_id"); id != "" {
|
||||
q = q.Where("tunnel_id = ?", id)
|
||||
}
|
||||
q.Find(&users)
|
||||
c.JSON(200, users)
|
||||
}
|
||||
func (s *Server) createVPNUser(c *gin.Context) {
|
||||
var req struct {
|
||||
TunnelID uint `json:"tunnel_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Source string `json:"source"`
|
||||
LDAPDN string `json:"ldap_dn"`
|
||||
Password string `json:"password"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || req.Username == "" {
|
||||
c.JSON(400, gin.H{"error": "隧道和用户名必填"})
|
||||
return
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = "local"
|
||||
}
|
||||
if req.Source != "local" && req.Source != "ldap" {
|
||||
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
|
||||
return
|
||||
}
|
||||
user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true}
|
||||
if req.Enabled != nil {
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if user.Source == "local" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(400, gin.H{"error": "本地拨号用户密码必填"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
} else if user.LDAPDN == "" {
|
||||
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
|
||||
return
|
||||
}
|
||||
if err := store.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, user)
|
||||
}
|
||||
func (s *Server) updateVPNUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var user model.VPNUser
|
||||
if store.DB.First(&user, id).Error != nil {
|
||||
c.JSON(404, gin.H{"error": "拨号用户不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName, Email, Password string
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
user.DisplayName, user.Email = req.DisplayName, req.Email
|
||||
if req.Enabled != nil {
|
||||
user.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Password != "" {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user.Password = string(hash)
|
||||
}
|
||||
store.DB.Save(&user)
|
||||
c.JSON(200, user)
|
||||
}
|
||||
func (s *Server) deleteVPNUser(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
store.DB.Delete(&model.VPNUser{}, id)
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package core 定义 VPN 协议统一抽象接口与运行时管理器。
|
||||
// 所有协议(内置或自定义插件)只需实现 Protocol 接口即可接入平台。
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
)
|
||||
|
||||
// Status 专线运行状态
|
||||
type Status struct {
|
||||
Running bool `json:"running"`
|
||||
State string `json:"state"` // running / stopped / error / undeployed
|
||||
Detail string `json:"detail"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
// Peers 为协议可选的对端实时状态。WireGuard 等多对端协议使用它向
|
||||
// 管理端返回逐 Peer 的握手时间、端点和内核计数器,不能用隧道总流量冒充。
|
||||
Peers []PeerStatus `json:"peers,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PeerStatus 是多对端协议的真实运行时数据。RxBytes/TxBytes 来自协议
|
||||
// 进程或内核的累计计数器;服务重启后计数器可能从零重新开始。
|
||||
type PeerStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
AllowedIPs string `json:"allowed_ips,omitempty"`
|
||||
LatestHandshakeAt int64 `json:"latest_handshake_at,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
// Protocol 统一协议接口 —— 自定义协议按此契约实现即可热插拔接入
|
||||
type Protocol interface {
|
||||
// Name 协议标识,如 wireguard / ipsec / frp
|
||||
Name() string
|
||||
// DisplayName 展示名
|
||||
DisplayName() string
|
||||
// Validate 创建前校验参数,返回规范化后的配置
|
||||
Validate(cfg map[string]interface{}) (map[string]interface{}, error)
|
||||
// Create 生成服务端配置并落盘(不启动)
|
||||
Create(t *model.Tunnel) error
|
||||
// Start 启动实例
|
||||
Start(t *model.Tunnel) error
|
||||
// Stop 停止实例
|
||||
Stop(t *model.Tunnel) error
|
||||
// Status 查询运行状态与流量
|
||||
Status(t *model.Tunnel) (Status, error)
|
||||
// GenerateClientConfig 生成客户端配置(用于下载 / 二维码)
|
||||
GenerateClientConfig(t *model.Tunnel) (string, error)
|
||||
// Delete 清理实例配置与运行时资源
|
||||
Delete(t *model.Tunnel) error
|
||||
// Available 检测系统依赖是否就绪(wg / strongswan / frps 等)
|
||||
Available() (bool, string)
|
||||
}
|
||||
|
||||
// Manager 协议注册中心 + 实例状态缓存
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
protocols map[string]Protocol
|
||||
statuses map[uint]Status
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{protocols: map[string]Protocol{}, statuses: map[uint]Status{}}
|
||||
}
|
||||
|
||||
// Register 注册协议实现(内置协议与自定义插件共用入口)
|
||||
func (m *Manager) Register(p Protocol) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.protocols[p.Name()] = p
|
||||
}
|
||||
|
||||
// Get 获取协议实现
|
||||
func (m *Manager) Get(name string) (Protocol, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
p, ok := m.protocols[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的协议类型: %s", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// List 列出全部已注册协议
|
||||
func (m *Manager) List() []Protocol {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]Protocol, 0, len(m.protocols))
|
||||
for _, p := range m.protocols {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetStatus 更新实例状态缓存(monitor 写入)
|
||||
func (m *Manager) SetStatus(tunnelID uint, s Status) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.statuses[tunnelID] = s
|
||||
}
|
||||
|
||||
// GetStatus 读取实例状态缓存
|
||||
func (m *Manager) GetStatus(tunnelID uint) (Status, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.statuses[tunnelID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// RemoveStatus 删除状态缓存
|
||||
func (m *Manager) RemoveStatus(tunnelID uint) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.statuses, tunnelID)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package model 定义平台数据模型(GORM + SQLite)
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User 平台账号
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||
Email string `gorm:"size:128" json:"email"`
|
||||
Password string `gorm:"size:128" json:"-"` // 仅本地用户保存 bcrypt 哈希
|
||||
Role string `gorm:"size:16;default:viewer" json:"role"` // admin / operator / viewer
|
||||
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LDAPConfig LDAP 身份源配置。BindPassword 由 store.EncryptSecret 加密后保存。
|
||||
type LDAPConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
URL string `gorm:"size:256" json:"url"`
|
||||
BindDN string `gorm:"size:256" json:"bind_dn"`
|
||||
BindPassword string `gorm:"type:text" json:"-"`
|
||||
BaseDN string `gorm:"size:256" json:"base_dn"`
|
||||
UserFilter string `gorm:"size:512" json:"user_filter"`
|
||||
UsernameAttribute string `gorm:"size:64" json:"username_attribute"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MFADevice 保存管理平台用户的 TOTP 密钥和备用码(均加密保存)。
|
||||
type MFADevice struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex" json:"user_id"`
|
||||
Secret string `gorm:"type:text" json:"-"`
|
||||
BackupCodes string `gorm:"type:text" json:"-"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VPNUser 是拨号身份,不等同于管理平台登录账号;可来自本地或 LDAP。
|
||||
type VPNUser struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||
Username string `gorm:"size:128;uniqueIndex:idx_vpn_user" json:"username"`
|
||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||
Email string `gorm:"size:128" json:"email"`
|
||||
Source string `gorm:"size:16;default:local" json:"source"` // local / ldap
|
||||
LDAPDN string `gorm:"size:512" json:"ldap_dn"`
|
||||
Password string `gorm:"size:128" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Certificate string `gorm:"size:128" json:"certificate"`
|
||||
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
|
||||
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tunnel 专线实例(所有协议统一存储,协议参数存 Config JSON)
|
||||
type Tunnel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;index" json:"name"`
|
||||
Protocol string `gorm:"size:32;index" json:"protocol"` // wireguard/ipsec/l2tp/frp/openvpn/custom:xxx
|
||||
Remark string `gorm:"size:512" json:"remark"`
|
||||
Config string `gorm:"type:text" json:"config"` // JSON,密钥字段加密存储
|
||||
Secrets string `gorm:"type:text" json:"-"` // AES-GCM 加密的敏感字段 JSON
|
||||
State string `gorm:"size:16;default:stopped" json:"state"` // running/stopped/error
|
||||
AutoStart bool `gorm:"default:false" json:"auto_start"`
|
||||
RxBytes int64 `gorm:"default:0" json:"rx_bytes"`
|
||||
TxBytes int64 `gorm:"default:0" json:"tx_bytes"`
|
||||
ExpireAt *time.Time `json:"expire_at"`
|
||||
NodeID *uint `json:"node_id"`
|
||||
Node *Node `json:"node,omitempty"`
|
||||
LastError string `gorm:"size:1024" json:"last_error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Node 中继节点(本机为内置节点)
|
||||
type Node struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128" json:"name"`
|
||||
Address string `gorm:"size:256" json:"address"` // 节点地址(API 或 SSH)
|
||||
Kind string `gorm:"size:32;default:local" json:"kind"` // local / remote
|
||||
Remark string `gorm:"size:512" json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Plugin 自定义协议插件(通过命令钩子实现 Protocol 契约)
|
||||
type Plugin struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:64" json:"name"` // 协议标识
|
||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
// 命令钩子:平台将专线 Config JSON 通过 stdin 传入,插件按约定动作执行
|
||||
CreateHook string `gorm:"size:512" json:"create_hook"`
|
||||
StartHook string `gorm:"size:512" json:"start_hook"`
|
||||
StopHook string `gorm:"size:512" json:"stop_hook"`
|
||||
StatusHook string `gorm:"size:512" json:"status_hook"`
|
||||
ConfigHook string `gorm:"size:512" json:"config_hook"` // 生成客户端配置
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AuditLog 操作审计
|
||||
type AuditLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64" json:"username"`
|
||||
Action string `gorm:"size:64" json:"action"`
|
||||
Target string `gorm:"size:256" json:"target"`
|
||||
Detail string `gorm:"size:1024" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package monitor 周期性采集各专线运行状态与流量
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// Start 启动状态采集循环(每 15 秒刷新一次)
|
||||
func Start(mgr *core.Manager, interval time.Duration) {
|
||||
if interval == 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
collect(mgr) // 启动即采集一次
|
||||
for range ticker.C {
|
||||
collect(mgr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func collect(mgr *core.Manager) {
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Find(&tunnels).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for i := range tunnels {
|
||||
t := &tunnels[i]
|
||||
p, err := mgr.Get(t.Protocol)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
st, err := p.Status(t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mgr.SetStatus(t.ID, st)
|
||||
// 数据库状态与协议真实状态同步,避免页面长期展示过时的“运行中”。
|
||||
if st.State != "" && st.State != t.State {
|
||||
store.DB.Model(t).Update("state", st.State)
|
||||
}
|
||||
// 流量持久化(仅在有增量时写库)
|
||||
if st.RxBytes > 0 || st.TxBytes > 0 {
|
||||
store.DB.Model(t).Updates(map[string]interface{}{"rx_bytes": st.RxBytes, "tx_bytes": st.TxBytes})
|
||||
}
|
||||
// 到期自动停止
|
||||
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) && t.State == "running" {
|
||||
_ = p.Stop(t)
|
||||
store.DB.Model(t).Update("state", "stopped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AutoStartAll 启动时拉起所有标记为自启的专线
|
||||
func AutoStartAll(mgr *core.Manager) {
|
||||
var tunnels []model.Tunnel
|
||||
if err := store.DB.Where("auto_start = ?", true).Find(&tunnels).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for i := range tunnels {
|
||||
t := &tunnels[i]
|
||||
if p, err := mgr.Get(t.Protocol); err == nil {
|
||||
if err := p.Start(t); err != nil {
|
||||
store.DB.Model(t).Updates(map[string]interface{}{"state": "error", "last_error": err.Error()})
|
||||
} else {
|
||||
store.DB.Model(t).Update("state", "running")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package service 提供协议实现共用的运行时工具函数
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// BaseDir 实例配置与运行时根目录(可通过 VPN_DATA_DIR 覆盖)
|
||||
func BaseDir() string {
|
||||
if v := os.Getenv("VPN_DATA_DIR"); v != "" {
|
||||
return v
|
||||
}
|
||||
exe, _ := os.Executable()
|
||||
if exe != "" {
|
||||
return filepath.Join(filepath.Dir(exe), "data")
|
||||
}
|
||||
return "data"
|
||||
}
|
||||
|
||||
// InstanceDir 专线实例目录
|
||||
func InstanceDir(t *model.Tunnel) string {
|
||||
return filepath.Join(BaseDir(), "instances", fmt.Sprintf("%d-%s", t.ID, t.Protocol))
|
||||
}
|
||||
|
||||
// LookBin 检测系统依赖二进制
|
||||
func LookBin(names ...string) (string, bool) {
|
||||
for _, n := range names {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Run 执行命令并返回输出
|
||||
func Run(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
return out.String(), fmt.Errorf("%s %s 执行失败: %v %s", name, strings.Join(args, " "), err, errOut.String())
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// RequireRoot rejects service operations that need kernel networking privileges.
|
||||
// Starting the web process as an unprivileged account must never be reported as
|
||||
// a running VPN service merely because configuration generation succeeded.
|
||||
func RequireRoot(component string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("无法启动 %s:需要 root 权限以管理内核网络、IPSec SA 和防火墙规则", component)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireIPv4Forwarding verifies the kernel prerequisite for forwarding traffic
|
||||
// between a VPN address pool and the LAN/Internet.
|
||||
func RequireIPv4Forwarding(component string) error {
|
||||
sysctl, ok := LookBin("sysctl")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 %s:未检测到 sysctl,无法确认 net.ipv4.ip_forward", component)
|
||||
}
|
||||
out, err := Run(sysctl, "-n", "net.ipv4.ip_forward")
|
||||
if err != nil || strings.TrimSpace(out) != "1" {
|
||||
return fmt.Errorf("无法启动 %s:请先执行 sysctl -w net.ipv4.ip_forward=1", component)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// catches listeners started outside this platform.
|
||||
func RequireUDPPortAvailable(port int, component string) error {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法启动 %s:UDP %d 已被其他服务占用: %w", component, port, err)
|
||||
}
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
// DefaultRouteInterface returns the interface used for IPv4 Internet egress.
|
||||
// It is used only when a VPN configuration does not explicitly set one.
|
||||
func DefaultRouteInterface() (string, error) {
|
||||
ip, ok := LookBin("ip")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("未检测到 iproute2 的 ip 命令,无法确定 NAT 出口接口")
|
||||
}
|
||||
out, err := Run(ip, "-4", "route", "show", "default")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := strings.Fields(out)
|
||||
for i := 0; i+1 < len(fields); i++ {
|
||||
if fields[i] == "dev" {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到 IPv4 默认路由,无法确定 NAT 出口接口")
|
||||
}
|
||||
|
||||
// StartDaemon 以守护方式启动进程并记录 PID
|
||||
func StartDaemon(t *model.Tunnel, name string, args ...string) error {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(filepath.Join(InstanceDir(t), "instance.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Dir = InstanceDir(t)
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
_ = os.WriteFile(PidFile(t), []byte(strconv.Itoa(pid)), 0o600)
|
||||
go func() { _ = cmd.Wait(); logFile.Close() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PidFile 实例 PID 文件
|
||||
func PidFile(t *model.Tunnel) string {
|
||||
return filepath.Join(InstanceDir(t), "instance.pid")
|
||||
}
|
||||
|
||||
// ReadPid 读取实例 PID 并校验存活
|
||||
func ReadPid(t *model.Tunnel) (int, bool) {
|
||||
raw, err := os.ReadFile(PidFile(t))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
// signal 0 检测进程存活
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
return pid, false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// KillPid 停止实例进程
|
||||
func KillPid(t *model.Tunnel) error {
|
||||
pid, alive := ReadPid(t)
|
||||
if !alive {
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteConfig 写入实例配置文件(0600 权限)
|
||||
func WriteConfig(t *model.Tunnel, name, content string) (string, error) {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(InstanceDir(t), name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// RandomSecret 生成适合配置文件与 API 凭据使用的不可预测随机值。
|
||||
func RandomSecret(bytesLen int) (string, error) {
|
||||
raw := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// RemoveInstance 清理隧道的专属配置、日志与 PID 文件。
|
||||
func RemoveInstance(t *model.Tunnel) error {
|
||||
return os.RemoveAll(InstanceDir(t))
|
||||
}
|
||||
|
||||
// Str / StrOr / Num / Bool 从配置 map 中安全取值
|
||||
func Str(cfg map[string]interface{}, key string) string {
|
||||
if v, ok := cfg[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func StrOr(cfg map[string]interface{}, key, def string) string {
|
||||
if s := Str(cfg, key); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Num(cfg map[string]interface{}, key string, def float64) float64 {
|
||||
if v, ok := cfg[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Bool(cfg map[string]interface{}, key string, def bool) bool {
|
||||
if v, ok := cfg[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// MustValidate 公共校验:配置必须是合法 JSON 对象
|
||||
func MustValidate(cfg map[string]interface{}, required ...string) error {
|
||||
for _, k := range required {
|
||||
if Str(cfg, k) == "" && Num(cfg, k, 0) == 0 {
|
||||
return fmt.Errorf("缺少必填参数: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigJSON 序列化配置
|
||||
func ConfigJSON(cfg map[string]interface{}) string {
|
||||
raw, _ := json.Marshal(cfg)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// NowStatus 构造状态对象
|
||||
func NowStatus(running bool, state, detail string) core.Status {
|
||||
return core.Status{Running: running, State: state, Detail: detail, UpdatedAt: time.Now().Unix()}
|
||||
}
|
||||
|
||||
// MergeSecrets 从 cfg 中提取敏感字段,返回 (脱敏后的公开配置, 敏感配置)
|
||||
func MergeSecrets(cfg map[string]interface{}, secretKeys ...string) (public map[string]interface{}, secrets map[string]interface{}) {
|
||||
public = map[string]interface{}{}
|
||||
secrets = map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
isSecret := false
|
||||
for _, sk := range secretKeys {
|
||||
if k == sk {
|
||||
isSecret = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isSecret {
|
||||
secrets[k] = v
|
||||
} else {
|
||||
public[k] = v
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SaveTunnelConfig 将公开配置与加密密钥写回数据库
|
||||
func SaveTunnelConfig(t *model.Tunnel, public, secrets map[string]interface{}) error {
|
||||
t.Config = ConfigJSON(public)
|
||||
if len(secrets) > 0 {
|
||||
raw, _ := json.Marshal(secrets)
|
||||
enc, err := store.EncryptSecret(string(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Secrets = enc
|
||||
}
|
||||
return store.DB.Save(t).Error
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package custom 实现自定义协议插件:用户在平台注册命令钩子,
|
||||
// 平台按统一 Protocol 契约调用(Create/Start/Stop/Status/Config)。
|
||||
// 钩子通过 stdin 接收专线 Config JSON,约定 stdout 返回结果。
|
||||
package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// Service 以数据库中的 Plugin 定义为驱动的协议实现
|
||||
type Service struct {
|
||||
plugin *model.Plugin
|
||||
}
|
||||
|
||||
func New(p *model.Plugin) *Service { return &Service{plugin: p} }
|
||||
|
||||
func (s *Service) Name() string { return "custom:" + s.plugin.Name }
|
||||
func (s *Service) DisplayName() string { return s.plugin.DisplayName }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if s.plugin.StartHook == "" {
|
||||
return false, "插件未配置 start 钩子"
|
||||
}
|
||||
return true, "插件钩子: " + s.plugin.StartHook
|
||||
}
|
||||
|
||||
// runHook 执行钩子命令,stdin 传入专线上下文 JSON
|
||||
func (s *Service) runHook(hook string, t *model.Tunnel) (string, error) {
|
||||
if hook == "" {
|
||||
return "", fmt.Errorf("插件 [%s] 未配置该动作的钩子命令", s.plugin.Name)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"name": t.Name,
|
||||
"protocol": t.Protocol,
|
||||
"config": json.RawMessage(t.Config),
|
||||
})
|
||||
ctx := exec.Command("sh", "-c", hook)
|
||||
done := make(chan error, 1)
|
||||
ctx.Stdin = bytes.NewReader(payload)
|
||||
var out, errOut bytes.Buffer
|
||||
ctx.Stdout = &out
|
||||
ctx.Stderr = &errOut
|
||||
if err := ctx.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
go func() { done <- ctx.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return out.String(), fmt.Errorf("钩子执行失败: %v %s", err, errOut.String())
|
||||
}
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
case <-time.After(30 * time.Second):
|
||||
_ = ctx.Process.Kill()
|
||||
return "", fmt.Errorf("钩子执行超时(30s)")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
if s.plugin.CreateHook == "" {
|
||||
return nil // 可选
|
||||
}
|
||||
_, err := s.runHook(s.plugin.CreateHook, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
_, err := s.runHook(s.plugin.StartHook, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
if s.plugin.StopHook == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.runHook(s.plugin.StopHook, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if s.plugin.StatusHook == "" {
|
||||
return core.Status{State: "unknown", Detail: "插件未配置 status 钩子", UpdatedAt: timeNow()}, nil
|
||||
}
|
||||
out, err := s.runHook(s.plugin.StatusHook, t)
|
||||
if err != nil {
|
||||
return core.Status{Running: false, State: "error", Detail: err.Error(), UpdatedAt: timeNow()}, nil
|
||||
}
|
||||
// 约定:status 钩子 stdout 输出 running / stopped / 任意描述
|
||||
running := strings.EqualFold(out, "running") || strings.EqualFold(out, "up") || out == "0"
|
||||
state := "stopped"
|
||||
if running {
|
||||
state = "running"
|
||||
}
|
||||
return core.Status{Running: running, State: state, Detail: out, UpdatedAt: timeNow()}, nil
|
||||
}
|
||||
|
||||
func timeNow() int64 { return time.Now().Unix() }
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
if s.plugin.ConfigHook == "" {
|
||||
return "", fmt.Errorf("插件未配置 config 钩子,无法生成客户端配置")
|
||||
}
|
||||
return s.runHook(s.plugin.ConfigHook, t)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// LoadAll 从数据库加载全部启用的插件并注册到管理器
|
||||
func LoadAll(m *core.Manager) {
|
||||
if store.DB == nil {
|
||||
return
|
||||
}
|
||||
var plugins []model.Plugin
|
||||
if err := store.DB.Where("enabled = ?", true).Find(&plugins).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for i := range plugins {
|
||||
m.Register(New(&plugins[i]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// Package frp manages real frps/frpc processes and FRP's TOML configuration.
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// Proxy is the supported, repeatable FRP proxy rule. It is deliberately kept
|
||||
// as a JSON-friendly struct so callers may put a `proxies` array in Config.
|
||||
// The old single proxy_name fields remain supported for existing installations.
|
||||
type Proxy struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LocalIP string `json:"local_ip"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Locations []string `json:"locations"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite"`
|
||||
}
|
||||
|
||||
var safeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
func (s *Service) Name() string { return "frp" }
|
||||
func (s *Service) DisplayName() string { return "FRP 内网穿透" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("frps", "frpc"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 frps/frpc 二进制,可生成配置但无法启动"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := clone(cfg)
|
||||
mode := service.StrOr(out, "mode", "server")
|
||||
if mode != "server" && mode != "client" {
|
||||
return nil, fmt.Errorf("mode 必须为 server 或 client")
|
||||
}
|
||||
out["mode"] = mode
|
||||
if mode == "server" {
|
||||
setDefaultPort(out, "bind_port", 7000)
|
||||
setDefaultPort(out, "dashboard_port", 7500)
|
||||
for _, key := range []string{"bind_port", "dashboard_port"} {
|
||||
if err := validPort(key, service.Num(out, key, 0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"vhost_http_port", "vhost_https_port"} {
|
||||
if service.Num(out, key, 0) != 0 {
|
||||
if err := validPort(key, service.Num(out, key, 0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := service.MustValidate(out, "server_addr"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setDefaultPort(out, "server_port", 7000)
|
||||
if err := validPort("server_port", service.Num(out, "server_port", 0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rules, err := proxyRules(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := validateProxy(rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
out["proxies"] = proxiesJSON(rules)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setDefaultPort(cfg map[string]interface{}, key string, value int) {
|
||||
if service.Num(cfg, key, 0) == 0 {
|
||||
cfg[key] = value
|
||||
}
|
||||
}
|
||||
func validPort(name string, port float64) error {
|
||||
if port < 1 || port > 65535 || port != float64(int(port)) {
|
||||
return fmt.Errorf("%s 必须是 1-65535 的整数", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxy(p Proxy) error {
|
||||
if !safeName.MatchString(p.Name) {
|
||||
return fmt.Errorf("代理名称 %q 无效", p.Name)
|
||||
}
|
||||
if p.Type != "tcp" && p.Type != "udp" && p.Type != "http" && p.Type != "https" {
|
||||
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http 或 https", p.Name)
|
||||
}
|
||||
if p.LocalPort < 1 || p.LocalPort > 65535 {
|
||||
return fmt.Errorf("代理 %s 的 local_port 无效", p.Name)
|
||||
}
|
||||
if p.LocalIP == "" {
|
||||
p.LocalIP = "127.0.0.1"
|
||||
}
|
||||
if strings.ContainsAny(p.LocalIP, "\r\n\"'") {
|
||||
return fmt.Errorf("代理 %s 的 local_ip 非法", p.Name)
|
||||
}
|
||||
if p.Type == "tcp" || p.Type == "udp" {
|
||||
if p.RemotePort < 1 || p.RemotePort > 65535 {
|
||||
return fmt.Errorf("代理 %s 的 remote_port 无效", p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(p.CustomDomains) == 0 && p.Subdomain == "" {
|
||||
return fmt.Errorf("HTTP/HTTPS 代理 %s 必须填写 custom_domains 或 subdomain", p.Name)
|
||||
}
|
||||
domains := append([]string{}, p.CustomDomains...)
|
||||
if p.Subdomain != "" {
|
||||
domains = append(domains, p.Subdomain)
|
||||
}
|
||||
for _, d := range domains {
|
||||
if !validDomain(d) {
|
||||
return fmt.Errorf("代理 %s 的域名 %q 无效", p.Name, d)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDomain(v string) bool {
|
||||
v = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(v)), ".")
|
||||
if v == "" || len(v) > 253 || strings.ContainsAny(v, "\r\n\"' /\\") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(v, ".") {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
token := service.Str(cfg, "token")
|
||||
if token == "" {
|
||||
token = service.Str(secrets, "token")
|
||||
}
|
||||
if token == "" {
|
||||
token, err = service.RandomSecret(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
secrets["token"] = token
|
||||
dashboardPassword := service.Str(cfg, "dashboard_password")
|
||||
if dashboardPassword == "" {
|
||||
dashboardPassword = service.Str(secrets, "dashboard_password")
|
||||
}
|
||||
if dashboardPassword == "" {
|
||||
dashboardPassword, err = service.RandomSecret(24)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
secrets["dashboard_password"] = dashboardPassword
|
||||
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
if _, err = service.WriteConfig(t, "frps.toml", frpsConfig(cfg, token, dashboardPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err = service.WriteConfig(t, "frpc.toml", s.frpcConfig(cfg, token)); err != nil {
|
||||
return err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "token", "dashboard_password")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
func frpsConfig(cfg map[string]interface{}, token, password string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "bindPort = %d\nauth.method = \"token\"\nauth.token = %q\n\n", int(service.Num(cfg, "bind_port", 7000)), token)
|
||||
fmt.Fprintf(&b, "webServer.addr = %q\nwebServer.port = %d\nwebServer.user = %q\nwebServer.password = %q\n", service.StrOr(cfg, "dashboard_addr", "127.0.0.1"), int(service.Num(cfg, "dashboard_port", 7500)), service.StrOr(cfg, "dashboard_user", "admin"), password)
|
||||
if p := int(service.Num(cfg, "vhost_http_port", 0)); p > 0 {
|
||||
fmt.Fprintf(&b, "vhostHTTPPort = %d\n", p)
|
||||
}
|
||||
if p := int(service.Num(cfg, "vhost_https_port", 0)); p > 0 {
|
||||
fmt.Fprintf(&b, "vhostHTTPSPort = %d\n", p)
|
||||
}
|
||||
if ranges := allowPorts(service.Str(cfg, "allow_ports")); len(ranges) > 0 {
|
||||
fmt.Fprintf(&b, "allowPorts = [%s]\n", strings.Join(ranges, ", "))
|
||||
}
|
||||
b.WriteString("log.to = \"frps.log\"\nlog.level = \"info\"\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Service) frpcConfig(cfg map[string]interface{}, token string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "serverAddr = %q\nserverPort = %d\nauth.method = \"token\"\nauth.token = %q\nlog.to = \"frpc.log\"\nlog.level = \"info\"\n", service.Str(cfg, "server_addr"), int(service.Num(cfg, "server_port", 7000)), token)
|
||||
rules, _ := proxyRules(cfg)
|
||||
for _, p := range rules {
|
||||
fmt.Fprintf(&b, "\n[[proxies]]\nname = %q\ntype = %q\nlocalIP = %q\nlocalPort = %d\n", p.Name, p.Type, defaultString(p.LocalIP, "127.0.0.1"), p.LocalPort)
|
||||
if p.Type == "tcp" || p.Type == "udp" {
|
||||
fmt.Fprintf(&b, "remotePort = %d\n", p.RemotePort)
|
||||
} else {
|
||||
if len(p.CustomDomains) > 0 {
|
||||
fmt.Fprintf(&b, "customDomains = [%s]\n", strings.Join(quoteStrings(p.CustomDomains), ", "))
|
||||
}
|
||||
if p.Subdomain != "" {
|
||||
fmt.Fprintf(&b, "subdomain = %q\n", p.Subdomain)
|
||||
}
|
||||
if len(p.Locations) > 0 {
|
||||
fmt.Fprintf(&b, "locations = [%s]\n", strings.Join(quoteStrings(p.Locations), ", "))
|
||||
}
|
||||
if p.HostHeaderRewrite != "" {
|
||||
fmt.Fprintf(&b, "hostHeaderRewrite = %q\n", p.HostHeaderRewrite)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
token := service.Str(secrets, "token")
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
cfg = clone(cfg)
|
||||
cfg["server_addr"] = service.StrOr(cfg, "public_endpoint", "your-server-ip")
|
||||
cfg["server_port"] = service.Num(cfg, "bind_port", 7000)
|
||||
}
|
||||
return s.frpcConfig(cfg, token), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, conf := "frpc", "frpc.toml"
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin, conf = "frps", "frps.toml"
|
||||
}
|
||||
if p, ok := service.LookBin(bin); ok {
|
||||
return service.StartDaemon(t, p, "-c", fmt.Sprintf("%s/%s", service.InstanceDir(t), conf))
|
||||
}
|
||||
return fmt.Errorf("无法启动 FRP:未检测到 %s", bin)
|
||||
}
|
||||
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if _, alive := service.ReadPid(t); alive {
|
||||
return service.NowStatus(true, "running", "FRP 进程运行中"), nil
|
||||
}
|
||||
cfg, _ := store.TunnelConfig(t)
|
||||
bin := "frpc"
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin = "frps"
|
||||
}
|
||||
if _, ok := service.LookBin(bin); !ok {
|
||||
return service.NowStatus(false, "undeployed", fmt.Sprintf("配置已生成,但未检测到 %s,尚未部署", bin)), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
}
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
|
||||
if raw, ok := cfg["proxies"]; ok {
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("proxies 必须是数组")
|
||||
}
|
||||
rules := make([]Proxy, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("proxies 中每项必须是对象")
|
||||
}
|
||||
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite")}
|
||||
p.CustomDomains = stringList(m["custom_domains"])
|
||||
p.Locations = stringList(m["locations"])
|
||||
rules = append(rules, p)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
if name := service.Str(cfg, "proxy_name"); name != "" {
|
||||
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains"))}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func proxiesJSON(in []Proxy) []interface{} {
|
||||
out := make([]interface{}, 0, len(in))
|
||||
for _, p := range in {
|
||||
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func stringList(v interface{}) []string {
|
||||
switch x := v.(type) {
|
||||
case []interface{}:
|
||||
out := []string{}
|
||||
for _, item := range x {
|
||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return x
|
||||
case string:
|
||||
return splitDomains(x)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func splitDomains(v string) []string {
|
||||
return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' })
|
||||
}
|
||||
func quoteStrings(v []string) []string {
|
||||
out := make([]string, 0, len(v))
|
||||
for _, s := range v {
|
||||
out = append(out, strconv.Quote(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
func defaultString(v, d string) string {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func clone(in map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
func allowPorts(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
pieces := strings.Split(part, "-")
|
||||
a, err := strconv.Atoi(strings.TrimSpace(pieces[0]))
|
||||
if err != nil || a < 1 || a > 65535 || len(pieces) > 2 {
|
||||
continue
|
||||
}
|
||||
entry := ""
|
||||
if len(pieces) == 1 {
|
||||
entry = fmt.Sprintf("{ single = %d }", a)
|
||||
} else {
|
||||
b, err := strconv.Atoi(strings.TrimSpace(pieces[1]))
|
||||
if err != nil || b < a || b > 65535 {
|
||||
continue
|
||||
}
|
||||
entry = fmt.Sprintf("{ start = %d, end = %d }", a, b)
|
||||
}
|
||||
if !seen[entry] {
|
||||
seen[entry] = true
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMultipleHTTPAndTCPProxies(t *testing.T) {
|
||||
s := New()
|
||||
cfg, err := s.Validate(map[string]interface{}{
|
||||
"mode": "client",
|
||||
"server_addr": "frps.example.com",
|
||||
"proxies": []interface{}{
|
||||
map[string]interface{}{"name": "ssh", "type": "tcp", "local_port": float64(22), "remote_port": float64(2222)},
|
||||
map[string]interface{}{"name": "web", "type": "https", "local_port": float64(443), "custom_domains": []interface{}{"vpn.example.com"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := s.frpcConfig(cfg, "test-token")
|
||||
for _, expected := range []string{"name = \"ssh\"", "remotePort = 2222", "name = \"web\"", "customDomains = [\"vpn.example.com\"]"} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Errorf("generated config missing %q:\n%s", expected, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProxyNeedsDomain(t *testing.T) {
|
||||
_, err := New().Validate(map[string]interface{}{
|
||||
"mode": "client", "server_addr": "127.0.0.1",
|
||||
"proxies": []interface{}{map[string]interface{}{"name": "web", "type": "http", "local_port": float64(8080)}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP domain validation error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Package ipsec 实现 IPSec 站点到站点对接管理(StrongSwan swanctl),
|
||||
// 内置 Cisco / FortiGate / 华为 / 华三 常用对接模板,重点服务第三方对接场景。
|
||||
package ipsec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
|
||||
func (s *Service) Name() string { return "ipsec" }
|
||||
func (s *Service) DisplayName() string { return "IPSec 站点互联" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("swanctl"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动"
|
||||
}
|
||||
|
||||
// VendorTemplate 第三方设备对接模板
|
||||
type VendorTemplate struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
IKEVersion string `json:"ike_version"`
|
||||
IKEProposal string `json:"ike_proposal"`
|
||||
ESPProposal string `json:"esp_proposal"`
|
||||
DPDAction string `json:"dpd_action"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Templates 常用第三方设备模板(Phase1 / Phase2 参数预置)
|
||||
func Templates() []VendorTemplate {
|
||||
return []VendorTemplate{
|
||||
{"cisco", "Cisco ISR/ASR", "ikev2", "aes256-sha256-modp2048", "aes256-sha256", "restart", "思科设备建议 IKEv2 + DH group14,注意对端 lifetime 默认 86400s"},
|
||||
{"fortigate", "FortiGate 防火墙", "ikev2", "aes256-sha256-modp1536", "aes256-sha256", "clear", "飞塔默认 DH group5,Phase2 建议开启 PFS group14"},
|
||||
{"huawei", "华为 USG/AR", "ikev2", "aes256-sha1-modp1024", "aes256-sha1", "restart", "华为老设备常用 DH group2,建议升级到 modp2048"},
|
||||
{"h3c", "H3C 防火墙", "ikev1", "aes128-sha1-modp1024", "aes128-sha1", "restart", "华三老型号仅支持 IKEv1 主模式,需关闭 DPD 周期性探测"},
|
||||
{"generic", "通用标准(推荐)", "ikev2", "aes256gcm16-sha256-modp2048", "aes256gcm16", "restart", "现代设备首选:AES-GCM 认证加密,性能与安全兼顾"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := service.MustValidate(cfg, "remote_gateway", "local_subnets", "remote_subnets"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if service.Str(out, "ike_version") == "" {
|
||||
out["ike_version"] = "ikev2"
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048"
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = "aes256-sha256"
|
||||
}
|
||||
if service.Str(out, "dpd_action") == "" {
|
||||
out["dpd_action"] = "restart"
|
||||
}
|
||||
if service.Str(out, "local_id") == "" {
|
||||
out["local_id"] = "@vpn-local"
|
||||
}
|
||||
if service.Str(out, "remote_id") == "" {
|
||||
out["remote_id"] = "@vpn-remote"
|
||||
}
|
||||
remoteGateway := service.Str(out, "remote_gateway")
|
||||
// StrongSwan accepts both literal addresses and DNS names. Keep DNS support
|
||||
// for peers with dynamic addresses, while rejecting syntax that can alter the
|
||||
// generated swanctl stanza.
|
||||
if _, err := netip.ParseAddr(remoteGateway); err != nil &&
|
||||
(strings.ContainsAny(remoteGateway, " \t\r\n{}\"") || !strings.ContainsAny(remoteGateway, ".abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) {
|
||||
return nil, fmt.Errorf("remote_gateway 必须是 IPv4/IPv6 地址或有效域名")
|
||||
}
|
||||
if err := validatePrefixes(service.Str(out, "local_subnets")); err != nil {
|
||||
return nil, fmt.Errorf("local_subnets 无效: %w", err)
|
||||
}
|
||||
if err := validatePrefixes(service.Str(out, "remote_subnets")); err != nil {
|
||||
return nil, fmt.Errorf("remote_subnets 无效: %w", err)
|
||||
}
|
||||
if intersects(service.Str(out, "local_subnets"), service.Str(out, "remote_subnets")) {
|
||||
return nil, fmt.Errorf("local_subnets 与 remote_subnets 不能重叠")
|
||||
}
|
||||
for _, key := range []string{"ike_proposal", "esp_proposal", "local_id", "remote_id", "dpd_action"} {
|
||||
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
|
||||
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePrefixes(raw string) error {
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
if _, err := netip.ParsePrefix(strings.TrimSpace(item)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intersects(a, b string) bool {
|
||||
for _, left := range strings.Split(a, ",") {
|
||||
lp, err := netip.ParsePrefix(strings.TrimSpace(left))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, right := range strings.Split(b, ",") {
|
||||
rp, err := netip.ParsePrefix(strings.TrimSpace(right))
|
||||
if err == nil && (lp.Overlaps(rp)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
if psk := service.Str(cfg, "psk"); psk != "" {
|
||||
secrets["psk"] = psk // 显式提交新 PSK 时覆盖旧值
|
||||
} else if service.Str(secrets, "psk") == "" {
|
||||
return fmt.Errorf("缺少预共享密钥 psk(第三方对接必填)")
|
||||
}
|
||||
|
||||
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"))
|
||||
if _, err := service.WriteConfig(t, "swanctl.conf", conf); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同时导出一份对端参考配置(方便发给第三方)
|
||||
peerRef := s.PeerReference(cfg)
|
||||
if _, err := service.WriteConfig(t, "peer-reference.txt", peerRef); err != nil {
|
||||
return err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "psk")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// swanctlConf 生成 StrongSwan swanctl 配置
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk string) string {
|
||||
vip := ""
|
||||
if v := service.Str(cfg, "local_ts_vip"); v != "" {
|
||||
vip = "\n vips = " + v
|
||||
}
|
||||
return fmt.Sprintf(`connections {
|
||||
tunnel-%d {
|
||||
remote_addrs = %s
|
||||
version = %s
|
||||
dpd_delay = 30s
|
||||
dpd_timeout = 120s
|
||||
proposals = %s
|
||||
local {
|
||||
auth = psk
|
||||
id = %s
|
||||
}
|
||||
remote {
|
||||
auth = psk
|
||||
id = %s
|
||||
}
|
||||
children {
|
||||
site-%d {
|
||||
local_ts = %s
|
||||
remote_ts = %s
|
||||
esp_proposals = %s
|
||||
dpd_action = %s
|
||||
start_action = start%s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
secrets {
|
||||
ike-tunnel-%d {
|
||||
id-local = %s
|
||||
id-remote = %s
|
||||
secret = %s
|
||||
}
|
||||
}
|
||||
`,
|
||||
id,
|
||||
service.Str(cfg, "remote_gateway"),
|
||||
ikeVersionNum(service.Str(cfg, "ike_version")),
|
||||
service.Str(cfg, "ike_proposal"),
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(cfg, "remote_id"),
|
||||
id,
|
||||
service.Str(cfg, "local_subnets"),
|
||||
service.Str(cfg, "remote_subnets"),
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
service.Str(cfg, "dpd_action"),
|
||||
vip,
|
||||
id,
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(cfg, "remote_id"),
|
||||
strconv.Quote(psk),
|
||||
)
|
||||
}
|
||||
|
||||
func ikeVersionNum(v string) string {
|
||||
if strings.EqualFold(v, "ikev1") {
|
||||
return "1"
|
||||
}
|
||||
return "2"
|
||||
}
|
||||
|
||||
// PeerReference 生成对端(第三方)参数参考文档
|
||||
func (s *Service) PeerReference(cfg map[string]interface{}) string {
|
||||
return fmt.Sprintf(`======== IPSec 对接参数(请提供给对端管理员)========
|
||||
|
||||
本端网关公网地址 : %s
|
||||
对端网关地址 : %s
|
||||
IKE 版本 : %s
|
||||
认证方式 : 预共享密钥 (PSK)
|
||||
|
||||
----- Phase 1 (IKE SA) -----
|
||||
加密/认证/DH : %s
|
||||
本端标识 ID : %s
|
||||
对端标识 ID : %s
|
||||
SA 生存期 : %ds
|
||||
DPD 策略 : %s (30s 探测 / 120s 超时)
|
||||
|
||||
----- Phase 2 (IPSec SA) -----
|
||||
封装协议 : ESP (隧道模式)
|
||||
加密/认证 : %s
|
||||
本端网段 : %s
|
||||
对端网段 : %s
|
||||
PFS : %s
|
||||
|
||||
注意:双方 Phase1/Phase2 的加密算法、认证算法、DH 组必须完全一致,
|
||||
两端 ID 需交叉对应(本端 local_id = 对端 remote_id)。
|
||||
`,
|
||||
service.StrOr(cfg, "public_endpoint", "<本端公网 IP>"),
|
||||
service.Str(cfg, "remote_gateway"),
|
||||
service.Str(cfg, "ike_version"),
|
||||
service.Str(cfg, "ike_proposal"),
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(cfg, "remote_id"),
|
||||
int(service.Num(cfg, "ike_lifetime", 86400)),
|
||||
service.Str(cfg, "dpd_action"),
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
service.Str(cfg, "local_subnets"),
|
||||
service.Str(cfg, "remote_subnets"),
|
||||
service.StrOr(cfg, "pfs_group", "modp2048"),
|
||||
)
|
||||
}
|
||||
|
||||
// ComparePeerParams 双方参数一致性检查(返回不一致项列表)
|
||||
func ComparePeerParams(local, peer map[string]interface{}) []string {
|
||||
var diffs []string
|
||||
checks := []struct{ key, label string }{
|
||||
{"ike_version", "IKE 版本"},
|
||||
{"ike_proposal", "Phase1 加密/认证/DH 提议"},
|
||||
{"esp_proposal", "Phase2 ESP 提议"},
|
||||
{"pfs_group", "PFS DH 组"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
lv, pv := service.Str(local, c.key), service.Str(peer, c.key)
|
||||
if pv != "" && lv != pv {
|
||||
diffs = append(diffs, fmt.Sprintf("%s 不一致:本端 [%s] vs 对端 [%s]", c.label, lv, pv))
|
||||
}
|
||||
}
|
||||
// 网段交叉检查:本端 local 应等于对端 remote
|
||||
if service.Str(peer, "local_subnets") != "" &&
|
||||
service.Str(local, "remote_subnets") != service.Str(peer, "local_subnets") {
|
||||
diffs = append(diffs, "网段交叉不匹配:本端 remote_subnets 应等于对端 local_subnets")
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
// GenerateClientConfig 导出对端参考配置(第三方对接场景)
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.PeerReference(cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
confPath := fmt.Sprintf("%s/swanctl.conf", service.InstanceDir(t))
|
||||
bin, ok := service.LookBin("swanctl")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IPSec:未检测到 swanctl,请先安装 strongSwan")
|
||||
}
|
||||
if err := service.RequireRoot("IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(confPath); err != nil {
|
||||
return fmt.Errorf("无法启动 IPSec:配置文件不存在,请先重新生成配置: %w", err)
|
||||
}
|
||||
if err := s.installFirewall(t); err != nil {
|
||||
return err
|
||||
}
|
||||
// Loading a revised configuration without unloading the previous connection
|
||||
// can leave stale traffic selectors and credentials in charon.
|
||||
_ = s.unload(bin, confPath)
|
||||
if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil {
|
||||
_ = s.removeFirewall(t)
|
||||
return err
|
||||
}
|
||||
if _, err := service.Run(bin, "--initiate", "--child", fmt.Sprintf("site-%d", t.ID)); err != nil {
|
||||
_ = s.unload(bin, confPath)
|
||||
_ = s.removeFirewall(t)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
var firstErr error
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
if _, err := service.Run(bin, "--terminate", "--ike", fmt.Sprintf("tunnel-%d", t.ID)); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
if err := s.unload(bin, fmt.Sprintf("%s/swanctl.conf", service.InstanceDir(t))); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if err := s.removeFirewall(t); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
out, err := service.Run(bin, "--list-sas")
|
||||
child := fmt.Sprintf("site-%d", t.ID)
|
||||
if err == nil && strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
|
||||
st := service.NowStatus(true, "running", "IKE/Child SA 已建立")
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), nil
|
||||
}
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// Both commands are idempotent from the platform perspective: an already
|
||||
// unloaded connection should not prevent stop/delete from cleaning its files.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IPSec:未检测到 iptables,无法放行 UDP 500/4500 与 ESP")
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ipsec-%d", t.ID)
|
||||
rules := [][]string{
|
||||
{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
check := append([]string{"-C", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append([]string{"-A", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, add...); err != nil {
|
||||
_ = s.removeFirewall(t)
|
||||
return fmt.Errorf("无法添加 IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeFirewall(t *model.Tunnel) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return nil // Service removal must still work after a package uninstall.
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ipsec-%d", t.ID)
|
||||
rules := [][]string{
|
||||
{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
for {
|
||||
check := append([]string{"-C", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, check...); err != nil {
|
||||
break
|
||||
}
|
||||
remove := append([]string{"-D", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, remove...); err != nil {
|
||||
return fmt.Errorf("无法移除 IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Package l2tp 实现 L2TP/IPSec 接入管理(StrongSwan + xl2tpd),兼容手机/老设备。
|
||||
package l2tp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
|
||||
func (s *Service) Name() string { return "l2tp" }
|
||||
func (s *Service) DisplayName() string { return "L2TP/IPSec" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
_, hasSwan := service.LookBin("swanctl", "ipsec")
|
||||
_, hasL2tp := service.LookBin("xl2tpd")
|
||||
if hasSwan && hasL2tp {
|
||||
return true, "strongSwan + xl2tpd"
|
||||
}
|
||||
return false, "未检测到 strongSwan/xl2tpd,可生成配置但无法启动"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if service.Str(out, "ip_range") == "" {
|
||||
out["ip_range"] = "10.9.0.100-10.9.0.200"
|
||||
}
|
||||
if service.Str(out, "local_ip") == "" {
|
||||
out["local_ip"] = "10.9.0.1"
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048,aes128-sha1-modp1024"
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = "aes256-sha256,aes128-sha1"
|
||||
}
|
||||
local, err := netip.ParseAddr(service.Str(out, "local_ip"))
|
||||
if err != nil || !local.Is4() {
|
||||
return nil, fmt.Errorf("local_ip 必须是 IPv4 地址")
|
||||
}
|
||||
start, end, err := parseIPRange(service.Str(out, "ip_range"))
|
||||
if err != nil || !start.Is4() || !end.Is4() || start.Compare(end) > 0 {
|
||||
return nil, fmt.Errorf("ip_range 必须是递增的 IPv4 地址范围")
|
||||
}
|
||||
if local.Compare(start) >= 0 && local.Compare(end) <= 0 {
|
||||
return nil, fmt.Errorf("local_ip 不能落在拨号客户端 ip_range 内")
|
||||
}
|
||||
if service.Str(out, "client_subnet") == "" {
|
||||
out["client_subnet"] = netip.PrefixFrom(start, 24).Masked().String()
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(service.Str(out, "client_subnet"))
|
||||
if err != nil || !prefix.Addr().Is4() || !prefix.Contains(start) || !prefix.Contains(end) {
|
||||
return nil, fmt.Errorf("client_subnet 必须覆盖整个 ip_range")
|
||||
}
|
||||
if username := service.Str(out, "username"); username != "" && strings.ContainsAny(username, " \t\r\n\"") {
|
||||
return nil, fmt.Errorf("username 不能包含空白或引号")
|
||||
}
|
||||
for _, key := range []string{"ike_proposal", "esp_proposal"} {
|
||||
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
|
||||
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseIPRange(raw string) (netip.Addr, netip.Addr, error) {
|
||||
parts := strings.Split(raw, "-")
|
||||
if len(parts) != 2 {
|
||||
return netip.Addr{}, netip.Addr{}, fmt.Errorf("格式应为 起始IP-结束IP")
|
||||
}
|
||||
start, err := netip.ParseAddr(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return netip.Addr{}, netip.Addr{}, err
|
||||
}
|
||||
end, err := netip.ParseAddr(strings.TrimSpace(parts[1]))
|
||||
return start, end, err
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
if psk := service.Str(cfg, "psk"); psk != "" {
|
||||
secrets["psk"] = psk
|
||||
} else if service.Str(secrets, "psk") == "" {
|
||||
return fmt.Errorf("缺少 IPSec 预共享密钥 psk")
|
||||
}
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
if pw := service.Str(cfg, "password"); pw != "" {
|
||||
secrets["password"] = pw
|
||||
} else if service.Str(secrets, "password") == "" {
|
||||
return fmt.Errorf("缺少拨号密码 password")
|
||||
}
|
||||
|
||||
// 1) IPSec 层(transport 模式,L2TP over IPSec)
|
||||
swanConf := fmt.Sprintf(`connections {
|
||||
l2tp-%d {
|
||||
type = transport
|
||||
proposals = %s
|
||||
local { auth = psk }
|
||||
remote { auth = psk }
|
||||
children {
|
||||
l2tp-child-%d {
|
||||
local_ts = dynamic[udp/l2tp]
|
||||
remote_ts = dynamic[udp/l2tp]
|
||||
esp_proposals = %s
|
||||
start_action = trap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
secrets {
|
||||
ike-l2tp-%d {
|
||||
secret = %s
|
||||
}
|
||||
}
|
||||
`, t.ID, service.Str(cfg, "ike_proposal"), t.ID, service.Str(cfg, "esp_proposal"), t.ID, strconv.Quote(service.Str(secrets, "psk")))
|
||||
|
||||
// 2) L2TP 层
|
||||
xl2tpdConf := fmt.Sprintf(`[global]
|
||||
port = 1701
|
||||
access control = yes
|
||||
auth file = %s/chap-secrets
|
||||
|
||||
[lns default]
|
||||
ip range = %s
|
||||
local ip = %s
|
||||
require chap = yes
|
||||
refuse pap = yes
|
||||
require authentication = yes
|
||||
name = opsx-l2tp-%d
|
||||
ppp debug = no
|
||||
pppoptfile = %s/ppp-options
|
||||
length bit = yes
|
||||
`, service.InstanceDir(t), service.Str(cfg, "ip_range"), service.Str(cfg, "local_ip"), t.ID, service.InstanceDir(t))
|
||||
|
||||
pppOpts := fmt.Sprintf(`ms-dns %s
|
||||
asyncmap 0
|
||||
auth
|
||||
ipcp-accept-local
|
||||
ipcp-accept-remote
|
||||
nodefaultroute
|
||||
crtscts
|
||||
lock
|
||||
hide-password
|
||||
modem
|
||||
debug
|
||||
name l2tpd
|
||||
proxyarp
|
||||
lcp-echo-interval 30
|
||||
lcp-echo-failure 4
|
||||
mtu 1400
|
||||
mru 1400
|
||||
require-mschap-v2
|
||||
`, service.Str(cfg, "dns"))
|
||||
|
||||
chapSecrets := fmt.Sprintf(`# client server secret IP addresses
|
||||
%s l2tpd %s *
|
||||
`, username, strconv.Quote(service.Str(secrets, "password")))
|
||||
|
||||
for name, content := range map[string]string{
|
||||
"swanctl-l2tp.conf": swanConf,
|
||||
"xl2tpd.conf": xl2tpdConf,
|
||||
"ppp-options": pppOpts,
|
||||
"chap-secrets": chapSecrets,
|
||||
} {
|
||||
if _, err := service.WriteConfig(t, name, content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "psk", "password")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// GenerateClientConfig 导出客户端接入说明(手机/Windows 手动配置)
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
return fmt.Sprintf(`======== L2TP/IPSec 客户端接入信息 ========
|
||||
|
||||
服务器地址 : %s
|
||||
VPN 类型 : L2TP/IPSec PSK(预共享密钥)
|
||||
IPSec PSK : %s
|
||||
用户名 : %s
|
||||
密码 : %s
|
||||
|
||||
客户端配置指引:
|
||||
- iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥
|
||||
- Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T
|
||||
- Android : 部分新机型已移除 L2TP,建议改用 WireGuard
|
||||
- 路由器 : 选择 L2TP 拨号,填入上述参数
|
||||
`,
|
||||
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"),
|
||||
secrets["psk"],
|
||||
service.StrOr(cfg, "username", "vpnuser"),
|
||||
secrets["password"],
|
||||
), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
dir := service.InstanceDir(t)
|
||||
swanctl, hasSwan := service.LookBin("swanctl")
|
||||
xl2tpd, hasL2tp := service.LookBin("xl2tpd")
|
||||
if !hasSwan || !hasL2tp {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:需要 strongSwan(swanctl)和 xl2tpd")
|
||||
}
|
||||
if err := service.RequireRoot("L2TP/IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("L2TP/IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dir + "/swanctl-l2tp.conf"); err != nil {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:配置文件不存在,请先重新生成配置: %w", err)
|
||||
}
|
||||
if err := s.ensureExclusiveListener(t); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireUDPPortAvailable(1701, "L2TP/IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.installFirewall(t, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
|
||||
if _, err := service.Run(swanctl, "--load-all", "--file", dir+"/swanctl-l2tp.conf"); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return err
|
||||
}
|
||||
if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-D"); err != nil {
|
||||
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
var firstErr error
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
_, _ = service.Run(bin, "--terminate", "--ike", fmt.Sprintf("l2tp-%d", t.ID))
|
||||
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-l2tp.conf"); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if err := service.KillPid(t); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if cfg, err := store.TunnelConfig(t); err == nil {
|
||||
if err := s.removeFirewall(t, cfg); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if _, alive := service.ReadPid(t); alive {
|
||||
detail := "L2TP 服务正在 UDP 1701 监听,等待客户端连接"
|
||||
if _, ok := service.LookBin("xl2tpd"); !ok {
|
||||
detail = "托管进程仍在运行,但 xl2tpd 当前不可用"
|
||||
}
|
||||
if swanctl, ok := service.LookBin("swanctl"); ok {
|
||||
if out, err := service.Run(swanctl, "--list-sas"); err == nil && strings.Contains(out, fmt.Sprintf("l2tp-child-%d", t.ID)) {
|
||||
detail = "L2TP/IPSec 服务运行中,已有已建立的 IPSec SA"
|
||||
}
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
if ok, _ := s.Available(); !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但缺少 strongSwan 或 xl2tpd,尚未部署"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// xl2tpd owns a host-wide UDP socket. Independent configs cannot safely share
|
||||
// it; without this guard a later instance would look started but accept no VPN
|
||||
// sessions. Network namespaces can deliberately lift this restriction later.
|
||||
func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
|
||||
var count int64
|
||||
if err := store.DB.Model(&model.Tunnel{}).Where("protocol = ? AND state = ? AND id <> ?", "l2tp", "running", t.ID).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("检查 L2TP 监听冲突失败: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:本机已有 L2TP 实例运行;xl2tpd 共享 UDP 1701,每台主机仅支持一个实例")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// swanctl reports an error when an object was never loaded. Treat that as
|
||||
// success so Stop/Delete remain idempotent after a partial failed startup.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type firewallRule struct {
|
||||
table, chain string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (s *Service) firewallRules(t *model.Tunnel, cfg map[string]interface{}) ([]firewallRule, error) {
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
var err error
|
||||
outbound, err = service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-l2tp-%d", t.ID)
|
||||
return []firewallRule{
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "1701", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "FORWARD", []string{"-i", "ppp+", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "FORWARD", []string{"-o", "ppp+", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "client_subnet"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:未检测到 iptables,无法配置防火墙和 NAT")
|
||||
}
|
||||
rules, err := s.firewallRules(t, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.chain)
|
||||
check = append(check, rule.args...)
|
||||
if _, err := service.Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append(append([]string{}, prefix...), "-A", rule.chain)
|
||||
add = append(add, rule.args...)
|
||||
if _, err := service.Run(iptables, add...); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return fmt.Errorf("无法添加 L2TP/IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rules, err := s.firewallRules(t, cfg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.table)
|
||||
}
|
||||
for {
|
||||
check := append(append([]string{}, prefix...), "-C", rule.chain)
|
||||
check = append(check, rule.args...)
|
||||
if _, err := service.Run(iptables, check...); err != nil {
|
||||
break
|
||||
}
|
||||
remove := append(append([]string{}, prefix...), "-D", rule.chain)
|
||||
remove = append(remove, rule.args...)
|
||||
if _, err := service.Run(iptables, remove...); err != nil {
|
||||
return fmt.Errorf("无法移除 L2TP/IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// Package openvpn manages a real OpenVPN server, its Easy-RSA PKI and dial-in
|
||||
// identities. It never creates a placeholder process when host dependencies
|
||||
// are missing.
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
var certificateName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
func (s *Service) Name() string { return "openvpn" }
|
||||
func (s *Service) DisplayName() string { return "OpenVPN" }
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("openvpn"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 openvpn 二进制,可生成配置但无法启动"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if service.Num(out, "port", 0) == 0 {
|
||||
out["port"] = 1194
|
||||
}
|
||||
if p := service.Num(out, "port", 0); p < 1 || p > 65535 || p != float64(int(p)) {
|
||||
return nil, fmt.Errorf("port 必须是 1-65535 的整数")
|
||||
}
|
||||
if service.Str(out, "proto") == "" {
|
||||
out["proto"] = "udp"
|
||||
}
|
||||
if p := service.Str(out, "proto"); p != "udp" && p != "tcp-server" {
|
||||
return nil, fmt.Errorf("proto 必须为 udp 或 tcp-server")
|
||||
}
|
||||
if service.Str(out, "subnet") == "" {
|
||||
out["subnet"] = "10.10.0.0 255.255.255.0"
|
||||
}
|
||||
if _, _, err := parseSubnet(service.Str(out, "subnet")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if service.Str(out, "cipher") == "" {
|
||||
out["cipher"] = "AES-256-GCM"
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9-]+$`).MatchString(service.Str(out, "cipher")) {
|
||||
return nil, fmt.Errorf("cipher 非法")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if net.ParseIP(service.Str(out, "dns")) == nil {
|
||||
return nil, fmt.Errorf("dns 必须是 IP 地址")
|
||||
}
|
||||
if service.Str(out, "auth_mode") == "" {
|
||||
out["auth_mode"] = "certificate"
|
||||
}
|
||||
switch service.Str(out, "auth_mode") {
|
||||
case "certificate", "password", "certificate+password":
|
||||
default:
|
||||
return nil, fmt.Errorf("auth_mode 必须为 certificate、password 或 certificate+password")
|
||||
}
|
||||
if _, ok := out["pki_auto_init"]; !ok {
|
||||
out["pki_auto_init"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseSubnet(v string) (net.IP, net.IPMask, error) {
|
||||
f := strings.Fields(v)
|
||||
if len(f) != 2 {
|
||||
return nil, nil, fmt.Errorf("subnet 必须为“网络地址 掩码”,例如 10.10.0.0 255.255.255.0")
|
||||
}
|
||||
ip := net.ParseIP(f[0]).To4()
|
||||
mask := net.ParseIP(f[1]).To4()
|
||||
if ip == nil || mask == nil {
|
||||
return nil, nil, fmt.Errorf("subnet 必须是 IPv4 网络")
|
||||
}
|
||||
return ip, net.IPMask(mask), nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := service.WriteConfig(t, "server.conf", s.serverConfig(t, cfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := service.WriteConfig(t, "auth-user-pass.sh", authScript(t)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(filepath.Join(service.InstanceDir(t), "auth-user-pass.sh"), 0o700)
|
||||
if _, err := service.WriteConfig(t, "init-pki.sh", pkiScript(t)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(filepath.Join(service.InstanceDir(t), "init-pki.sh"), 0o700)
|
||||
public, secrets := service.MergeSecrets(cfg)
|
||||
if err := service.SaveTunnelConfig(t, public, secrets); err != nil {
|
||||
return err
|
||||
}
|
||||
// PKI creation is intentionally best-effort: a missing easy-rsa must not be
|
||||
// reported as a running server. Start() verifies every required artifact.
|
||||
_, openvpnInstalled := service.LookBin("openvpn")
|
||||
if service.Bool(cfg, "pki_auto_init", true) && easyRSAPath() != "" && openvpnInstalled && pkiReady(t) != nil {
|
||||
return s.InitializePKI(t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) string {
|
||||
dir := service.InstanceDir(t)
|
||||
ca, cert, key, dh, ta := filepath.Join(dir, "pki", "ca.crt"), filepath.Join(dir, "pki", "issued", "server.crt"), filepath.Join(dir, "pki", "private", "server.key"), filepath.Join(dir, "pki", "dh.pem"), filepath.Join(dir, "ta.key")
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\nca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp"), ca, cert, key, dh, ta)
|
||||
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\npush \"dhcp-option DNS %s\"\npush \"redirect-gateway def1 bypass-dhcp\"\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"), service.StrOr(cfg, "dns", "223.5.5.5"))
|
||||
fmt.Fprintf(&b, "keepalive 10 120\ndata-ciphers %s:AES-128-GCM\npersist-key\npersist-tun\nstatus %q 10\nstatus-version 3\nverb 3\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), filepath.Join(dir, "openvpn-status.log"))
|
||||
if service.StrOr(cfg, "proto", "udp") == "udp" {
|
||||
b.WriteString("explicit-exit-notify 1\n")
|
||||
}
|
||||
switch service.StrOr(cfg, "auth_mode", "certificate") {
|
||||
case "password":
|
||||
b.WriteString("verify-client-cert none\nusername-as-common-name\n")
|
||||
case "certificate+password":
|
||||
b.WriteString("verify-client-cert require\n")
|
||||
default:
|
||||
b.WriteString("verify-client-cert require\n")
|
||||
}
|
||||
if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "certificate+password" {
|
||||
fmt.Fprintf(&b, "script-security 2\nauth-user-pass-verify %q via-file\n", filepath.Join(dir, "auth-user-pass.sh"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// InitializePKI creates the CA and server key material in the instance's
|
||||
// private directory. It is safe to call repeatedly after a successful setup.
|
||||
func (s *Service) InitializePKI(t *model.Tunnel) error {
|
||||
if pkiReady(t) == nil {
|
||||
return nil
|
||||
}
|
||||
easy := easyRSAPath()
|
||||
if easy == "" {
|
||||
return fmt.Errorf("无法初始化 OpenVPN PKI:未检测到 easy-rsa/easyrsa")
|
||||
}
|
||||
dir := service.InstanceDir(t)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "init-pki"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "--batch", "build-ca", "nopass"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "--batch", "build-server-full", "server", "nopass"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "gen-dh"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runEasyRSA(dir, easy, "gen-crl"); err != nil {
|
||||
return err
|
||||
}
|
||||
openvpn, ok := service.LookBin("openvpn")
|
||||
if !ok {
|
||||
return fmt.Errorf("PKI 证书已生成,但未检测到 openvpn,无法生成 tls-crypt 密钥")
|
||||
}
|
||||
cmd := exec.Command(openvpn, "--genkey", "secret", filepath.Join(dir, "ta.key"))
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("生成 tls-crypt 密钥失败: %v: %s", err, out)
|
||||
}
|
||||
return pkiReady(t)
|
||||
}
|
||||
|
||||
func easyRSAPath() string {
|
||||
if p, ok := service.LookBin("easyrsa"); ok {
|
||||
return p
|
||||
}
|
||||
for _, p := range []string{"/usr/share/easy-rsa/easyrsa", "/usr/local/share/easy-rsa/easyrsa"} {
|
||||
if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func runEasyRSA(dir, binary string, args ...string) error {
|
||||
cmd := exec.Command(binary, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "EASYRSA_BATCH=1", "EASYRSA_REQ_CN=OPSX-VPN-CA")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("easy-rsa %s 失败: %v: %s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func pkiReady(t *model.Tunnel) error {
|
||||
for _, p := range []string{"pki/ca.crt", "pki/issued/server.crt", "pki/private/server.key", "pki/dh.pem", "ta.key"} {
|
||||
if _, err := os.Stat(filepath.Join(service.InstanceDir(t), p)); err != nil {
|
||||
return fmt.Errorf("缺少 %s", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func pkiScript(t *model.Tunnel) string {
|
||||
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"
|
||||
}
|
||||
func easyRSAPathOrDefault() string {
|
||||
if p := easyRSAPath(); p != "" {
|
||||
return p
|
||||
}
|
||||
return "easyrsa"
|
||||
}
|
||||
func authScript(t *model.Tunnel) string {
|
||||
exe, _ := os.Executable()
|
||||
if exe == "" {
|
||||
exe = "opsx-vpn"
|
||||
}
|
||||
return "#!/bin/sh\nset -eu\nexec " + shellQuote(exe) + " openvpn-auth --tunnel " + strconv.FormatUint(uint64(t.ID), 10) + " --credentials \"$1\"\n"
|
||||
}
|
||||
func shellQuote(v string) string { return "'" + strings.ReplaceAll(v, "'", "'\\''") + "'" }
|
||||
|
||||
// IssueClientCertificate signs a per-VPNUser certificate and records the exact
|
||||
// certificate CN. LDAP users can use certificates as a second factor too.
|
||||
func (s *Service) IssueClientCertificate(t *model.Tunnel, user *model.VPNUser) error {
|
||||
if user.TunnelID != t.ID {
|
||||
return errors.New("拨号用户不属于该 OpenVPN 隧道")
|
||||
}
|
||||
if !user.Enabled {
|
||||
return errors.New("拨号用户已禁用")
|
||||
}
|
||||
if err := pkiReady(t); err != nil {
|
||||
return fmt.Errorf("无法签发客户端证书:%w", err)
|
||||
}
|
||||
name := user.Certificate
|
||||
if name == "" {
|
||||
name = certificateCN(user.Username)
|
||||
}
|
||||
if !certificateName.MatchString(name) {
|
||||
return fmt.Errorf("客户端证书名称无效")
|
||||
}
|
||||
cert := filepath.Join(service.InstanceDir(t), "pki", "issued", name+".crt")
|
||||
if _, err := os.Stat(cert); err != nil {
|
||||
easy := easyRSAPath()
|
||||
if easy == "" {
|
||||
return fmt.Errorf("未检测到 easy-rsa,无法签发客户端证书")
|
||||
}
|
||||
if err := runEasyRSA(service.InstanceDir(t), easy, "--batch", "build-client-full", name, "nopass"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
user.Certificate = name
|
||||
return store.DB.Save(user).Error
|
||||
}
|
||||
func certificateCN(username string) string {
|
||||
v := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, username)
|
||||
v = strings.Trim(v, "-_")
|
||||
if v == "" {
|
||||
v = "vpn-user"
|
||||
}
|
||||
if len(v) > 48 {
|
||||
v = v[:48]
|
||||
}
|
||||
return "u-" + v
|
||||
}
|
||||
|
||||
// GenerateUserConfig returns an immediately importable profile after signing
|
||||
// the user certificate. It replaces the former instructional, non-functional
|
||||
// profile generation.
|
||||
func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := service.StrOr(cfg, "auth_mode", "certificate")
|
||||
if mode != "password" {
|
||||
if err := s.IssueClientCertificate(t, user); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
dir := service.InstanceDir(t)
|
||||
read := func(p string) (string, error) { b, e := os.ReadFile(p); return string(b), e }
|
||||
ca, e := read(filepath.Join(dir, "pki", "ca.crt"))
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
ta, e := read(filepath.Join(dir, "ta.key"))
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
var cert, key string
|
||||
if mode != "password" {
|
||||
cert, e = read(filepath.Join(dir, "pki", "issued", user.Certificate+".crt"))
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
key, e = read(filepath.Join(dir, "pki", "private", user.Certificate+".key"))
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
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"))
|
||||
if mode == "password" || mode == "certificate+password" {
|
||||
b.WriteString("auth-user-pass\n")
|
||||
}
|
||||
b.WriteString("<ca>\n" + ca + "</ca>\n")
|
||||
if cert != "" {
|
||||
b.WriteString("<cert>\n" + cert + "</cert>\n<key>\n" + key + "</key>\n")
|
||||
}
|
||||
b.WriteString("<tls-crypt>\n" + ta + "</tls-crypt>\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
func clientProto(proto string) string {
|
||||
if proto == "tcp-server" {
|
||||
return "tcp-client"
|
||||
}
|
||||
return proto
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
username := service.Str(cfg, "client_username")
|
||||
if username == "" {
|
||||
return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置")
|
||||
}
|
||||
var user model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND username = ?", t.ID, username).First(&user).Error; err != nil {
|
||||
return "", fmt.Errorf("拨号用户 %q 不存在: %w", username, err)
|
||||
}
|
||||
return s.GenerateUserConfig(t, &user)
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
if _, ok := service.LookBin("openvpn"); !ok {
|
||||
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
|
||||
}
|
||||
if err := pkiReady(t); err != nil {
|
||||
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
|
||||
}
|
||||
return service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf"))
|
||||
}
|
||||
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
|
||||
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if _, alive := service.ReadPid(t); alive {
|
||||
st := service.NowStatus(true, "running", "OpenVPN 进程运行中")
|
||||
rx, tx := s.collectTraffic(t)
|
||||
st.RxBytes = t.RxBytes + rx
|
||||
st.TxBytes = t.TxBytes + tx
|
||||
return st, nil
|
||||
}
|
||||
if _, ok := service.LookBin("openvpn"); !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 openvpn,尚未部署"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
}
|
||||
|
||||
type trafficSnapshot map[string]trafficCounter
|
||||
type trafficCounter struct {
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
}
|
||||
|
||||
func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
raw, err := os.ReadFile(filepath.Join(service.InstanceDir(t), "openvpn-status.log"))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
snap := trafficSnapshot{}
|
||||
if b, e := os.ReadFile(filepath.Join(service.InstanceDir(t), "traffic-state.json")); e == nil {
|
||||
_ = json.Unmarshal(b, &snap)
|
||||
}
|
||||
next := trafficSnapshot{}
|
||||
var totalRx, totalTx int64
|
||||
now := time.Now()
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(line, ",")
|
||||
if len(f) < 7 || f[0] != "CLIENT_LIST" {
|
||||
continue
|
||||
}
|
||||
rx, e1 := strconv.ParseInt(f[5], 10, 64)
|
||||
tx, e2 := strconv.ParseInt(f[6], 10, 64)
|
||||
if e1 != nil || e2 != nil {
|
||||
continue
|
||||
}
|
||||
cn := f[1]
|
||||
username := cn
|
||||
if len(f) > 9 && f[9] != "" && f[9] != "UNDEF" {
|
||||
username = f[9]
|
||||
}
|
||||
key := username + "|" + f[2]
|
||||
old := snap[key]
|
||||
deltaRx, deltaTx := rx-old.Rx, tx-old.Tx
|
||||
if deltaRx < 0 {
|
||||
deltaRx = rx
|
||||
}
|
||||
if deltaTx < 0 {
|
||||
deltaTx = tx
|
||||
}
|
||||
totalRx += deltaRx
|
||||
totalTx += deltaTx
|
||||
next[key] = trafficCounter{rx, tx}
|
||||
if store.DB != nil && username != "" {
|
||||
store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND (username = ? OR certificate = ?)", t.ID, username, cn).Updates(map[string]interface{}{"rx_bytes": gorm.Expr("rx_bytes + ?", deltaRx), "tx_bytes": gorm.Expr("tx_bytes + ?", deltaTx), "last_seen_at": now})
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(next)
|
||||
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "traffic-state.json"), b, 0o600)
|
||||
return totalRx, totalTx
|
||||
}
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's
|
||||
// auth-user-pass-verify hook. It returns 0 only for enabled, tunnel-scoped
|
||||
// local users or LDAP users whose recorded LDAP DN accepts the password.
|
||||
func RunAuthHelper(args []string) error {
|
||||
fs := flag.NewFlagSet("openvpn-auth", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
tunnel := fs.Uint("tunnel", 0, "tunnel id")
|
||||
credentials := fs.String("credentials", "", "credentials file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *tunnel == 0 || *credentials == "" {
|
||||
return errors.New("--tunnel 和 --credentials 必填")
|
||||
}
|
||||
raw, err := os.ReadFile(*credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) == "" {
|
||||
return errors.New("认证凭据格式无效")
|
||||
}
|
||||
return Authenticate(uint(*tunnel), strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]))
|
||||
}
|
||||
func Authenticate(tunnelID uint, username, password string) error {
|
||||
if password == "" {
|
||||
return errors.New("密码为空")
|
||||
}
|
||||
var user model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND username = ? AND enabled = ?", tunnelID, username, true).First(&user).Error; err != nil {
|
||||
return errors.New("拨号用户不存在或已禁用")
|
||||
}
|
||||
if user.Source == "local" {
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return errors.New("密码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if user.Source != "ldap" || user.LDAPDN == "" {
|
||||
return errors.New("拨号用户认证来源无效")
|
||||
}
|
||||
var cfg model.LDAPConfig
|
||||
if err := store.DB.Where("enabled = ?", true).First(&cfg).Error; err != nil {
|
||||
return errors.New("LDAP 未启用")
|
||||
}
|
||||
url := cfg.URL
|
||||
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
|
||||
url = "ldap://" + url
|
||||
}
|
||||
conn, err := ldap.DialURL(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
if cfg.BindDN != "" {
|
||||
bind, err := store.DecryptSecret(cfg.BindPassword)
|
||||
if err != nil {
|
||||
return errors.New("LDAP 绑定密钥无效")
|
||||
}
|
||||
if err := conn.Bind(cfg.BindDN, bind); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := conn.Bind(user.LDAPDN, password); err != nil {
|
||||
return errors.New("LDAP 密码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateOpenVPNModes(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["proto"] != "udp" || cfg["port"] != 1194 {
|
||||
t.Fatalf("defaults not applied: %#v", cfg)
|
||||
}
|
||||
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid auth mode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateCNIsSafeAndStable(t *testing.T) {
|
||||
cn := certificateCN("张三 / alice@example.com")
|
||||
if !certificateName.MatchString(cn) || !strings.HasPrefix(cn, "u-") {
|
||||
t.Fatalf("unsafe CN: %q", cn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
// Package wireguard manages real WireGuard interfaces through wg-quick and wg.
|
||||
// Every tunnel owns one interface and any number of peers. Private material is
|
||||
// retained in Tunnel.Secrets; the JSON configuration only contains public keys.
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 51820
|
||||
maxListenPort = 60999
|
||||
handshakeAlive = 3 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
peerIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`)
|
||||
ifacePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,15}$`)
|
||||
portMu sync.Mutex
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// peer is the public, persisted description of a WireGuard peer. private_key
|
||||
// and preshared_key deliberately never appear here.
|
||||
type peer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PublicKey string `json:"public_key"`
|
||||
ClientAddress string `json:"client_address"`
|
||||
AllowedIPs string `json:"allowed_ips,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
type peerSecret struct {
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
PresharedKey string `json:"preshared_key,omitempty"`
|
||||
}
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
|
||||
func (s *Service) Name() string { return "wireguard" }
|
||||
func (s *Service) DisplayName() string { return "WireGuard" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("wg-quick"); ok {
|
||||
if _, wgOK := service.LookBin("wg"); wgOK {
|
||||
return true, p
|
||||
}
|
||||
}
|
||||
return false, "未检测到 wireguard-tools(需要 wg 与 wg-quick)"
|
||||
}
|
||||
|
||||
// genKey generates a WireGuard-compatible Curve25519 private/public key pair.
|
||||
func genKey() (priv, pub string, err error) {
|
||||
var sk [32]byte
|
||||
if _, err = rand.Read(sk[:]); err != nil {
|
||||
return
|
||||
}
|
||||
sk[0] &= 248
|
||||
sk[31] = (sk[31] & 127) | 64
|
||||
pk, err := curve25519.X25519(sk[:], curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
priv = base64.StdEncoding.EncodeToString(sk[:])
|
||||
pub = base64.StdEncoding.EncodeToString(pk)
|
||||
return
|
||||
}
|
||||
|
||||
func publicKey(privateKey string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(privateKey)
|
||||
if err != nil || len(raw) != curve25519.ScalarSize {
|
||||
return "", fmt.Errorf("WireGuard 私钥格式非法")
|
||||
}
|
||||
pub, err := curve25519.X25519(raw, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pub), nil
|
||||
}
|
||||
|
||||
func isWireGuardKey(key string) bool {
|
||||
raw, err := base64.StdEncoding.DecodeString(key)
|
||||
return err == nil && len(raw) == curve25519.PointSize
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := service.MustValidate(cfg, "address"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if !validCIDR(service.Str(out, "address")) {
|
||||
return nil, fmt.Errorf("address 必须是合法 CIDR")
|
||||
}
|
||||
port := int(service.Num(out, "listen_port", 0))
|
||||
if port < 0 || port > 65535 || (port > 0 && port < 1024) {
|
||||
return nil, fmt.Errorf("listen_port 必须是 1024-65535 之间的 UDP 端口")
|
||||
}
|
||||
// Port zero means "allocate a free WireGuard port when this tunnel is created".
|
||||
if port > 0 {
|
||||
out["listen_port"] = port
|
||||
}
|
||||
if service.Str(out, "allowed_ips") == "" {
|
||||
out["allowed_ips"] = "0.0.0.0/0, ::/0"
|
||||
}
|
||||
if err := validateCIDRList(service.Str(out, "allowed_ips")); err != nil {
|
||||
return nil, fmt.Errorf("allowed_ips: %w", err)
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if service.Num(out, "keepalive", 0) == 0 {
|
||||
out["keepalive"] = 25
|
||||
}
|
||||
keepalive := int(service.Num(out, "keepalive", 25))
|
||||
if keepalive < 0 || keepalive > 65535 {
|
||||
return nil, fmt.Errorf("keepalive 必须是 0-65535")
|
||||
}
|
||||
if egress := service.Str(out, "egress_interface"); egress != "" && !ifacePattern.MatchString(egress) {
|
||||
return nil, fmt.Errorf("egress_interface 包含非法字符")
|
||||
}
|
||||
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
|
||||
return nil, fmt.Errorf("启用 NAT 时必须填写 egress_interface")
|
||||
}
|
||||
if _, supplied := out["peers"]; supplied {
|
||||
peers, err := parsePeers(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePeerInput(peers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if addr := service.Str(out, "client_address"); addr != "" && !validCIDR(addr) {
|
||||
return nil, fmt.Errorf("client_address 必须是合法 CIDR")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
// Config allocation and persistence need to be serialised: otherwise two
|
||||
// concurrent create requests can both observe the same currently-free port.
|
||||
portMu.Lock()
|
||||
defer portMu.Unlock()
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if int(service.Num(cfg, "listen_port", 0)) == 0 {
|
||||
port, err := allocatePort(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["listen_port"] = port
|
||||
} else if err := ensurePortAvailable(t.ID, int(service.Num(cfg, "listen_port", 0))); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["interface_name"] = interfaceName(t)
|
||||
|
||||
serverPrivate, _ := secrets["server_private_key"].(string)
|
||||
if serverPrivate == "" {
|
||||
var serverPublic string
|
||||
serverPrivate, serverPublic, err = genKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["server_private_key"] = serverPrivate
|
||||
cfg["server_public_key"] = serverPublic
|
||||
} else {
|
||||
serverPublic, err := publicKey(serverPrivate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["server_public_key"] = serverPublic
|
||||
}
|
||||
|
||||
peers, peerSecrets, err := buildPeers(cfg, secrets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return fmt.Errorf("至少需要一个 WireGuard Peer")
|
||||
}
|
||||
cfg["peers"] = peersToConfig(peers)
|
||||
// Legacy single-client fields must not leak into Config or conflict with peers.
|
||||
delete(cfg, "client_address")
|
||||
delete(cfg, "client_public_key")
|
||||
delete(secrets, "client_private_key")
|
||||
delete(secrets, "preshared_key")
|
||||
secrets["peer_secrets"] = peerSecretsToConfig(peerSecrets)
|
||||
|
||||
serverConf := renderServerConfig(cfg, serverPrivate, peers, peerSecrets)
|
||||
if _, err := service.WriteConfig(t, interfaceName(t)+".conf", serverConf); err != nil {
|
||||
return err
|
||||
}
|
||||
// client.conf remains a convenience export for the first platform-managed peer.
|
||||
if clientConf, err := generateClientConfig(cfg, peers, peerSecrets, ""); err == nil {
|
||||
if _, err := service.WriteConfig(t, "client.conf", clientConf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
public, sec := service.MergeSecrets(cfg, "server_private_key")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// GenerateClientConfig exports the first managed peer for backward compatible
|
||||
// downloads. Use GeneratePeerClientConfig when the caller has selected a peer.
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return generateClientConfig(cfg, peers, parsePeerSecrets(secrets), "")
|
||||
}
|
||||
|
||||
// GeneratePeerClientConfig generates one device's configuration without exposing
|
||||
// any other peer's key material.
|
||||
func (s *Service) GeneratePeerClientConfig(t *model.Tunnel, peerID string) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peerSecrets := parsePeerSecrets(secrets)
|
||||
return generateClientConfig(cfg, peers, peerSecrets, peerID)
|
||||
}
|
||||
|
||||
func generateClientConfig(cfg map[string]interface{}, peers []peer, peerSecrets map[string]peerSecret, requestedID string) (string, error) {
|
||||
for _, p := range peers {
|
||||
if requestedID != "" && p.ID != requestedID {
|
||||
continue
|
||||
}
|
||||
if requestedID == "" && (!p.Managed || !p.Enabled) {
|
||||
continue
|
||||
}
|
||||
sec := peerSecrets[p.ID]
|
||||
if !p.Managed || sec.PrivateKey == "" {
|
||||
return "", fmt.Errorf("Peer %s 为外部设备,平台未保存其私钥", p.ID)
|
||||
}
|
||||
endpoint := service.StrOr(cfg, "public_endpoint", fmt.Sprintf("your-server-ip:%d", int(service.Num(cfg, "listen_port", defaultPort))))
|
||||
allowed := p.AllowedIPs
|
||||
if allowed == "" {
|
||||
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
return fmt.Sprintf(`[Interface]
|
||||
PrivateKey = %s
|
||||
Address = %s
|
||||
DNS = %s
|
||||
|
||||
[Peer]
|
||||
PublicKey = %s
|
||||
PresharedKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = %d
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
}
|
||||
if requestedID != "" {
|
||||
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
|
||||
}
|
||||
return "", fmt.Errorf("没有可下载配置的托管 Peer;外部 Peer 请在其设备上配置")
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
if available, _ := s.Available(); !available {
|
||||
return fmt.Errorf("无法启动 WireGuard:未检测到 wg 和 wg-quick,请先安装 wireguard-tools")
|
||||
}
|
||||
if _, err := service.Run("wg", "show", interfaceName(t)); err == nil {
|
||||
return nil
|
||||
}
|
||||
conf := fmt.Sprintf("%s/%s.conf", service.InstanceDir(t), interfaceName(t))
|
||||
if _, err := service.Run("wg-quick", "up", conf); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
if _, ok := service.LookBin("wg-quick"); !ok {
|
||||
return fmt.Errorf("无法停止 WireGuard:未检测到 wg-quick")
|
||||
}
|
||||
if _, ok := service.LookBin("wg"); ok {
|
||||
if _, err := service.Run("wg", "show", interfaceName(t)); err != nil {
|
||||
return nil // already stopped; do not claim a fake process was stopped
|
||||
}
|
||||
}
|
||||
conf := fmt.Sprintf("%s/%s.conf", service.InstanceDir(t), interfaceName(t))
|
||||
_, err := service.Run("wg-quick", "down", conf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
bin, ok := service.LookBin("wg")
|
||||
if !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 wireguard-tools(wg),尚未部署"), nil
|
||||
}
|
||||
dump, err := service.Run(bin, "show", interfaceName(t), "dump")
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "stopped", "WireGuard 接口未启动"), nil
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return core.Status{}, err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return core.Status{}, err
|
||||
}
|
||||
byKey := make(map[string]peer, len(peers))
|
||||
for _, p := range peers {
|
||||
byKey[p.PublicKey] = p
|
||||
}
|
||||
st := service.NowStatus(true, "running", "WireGuard 接口运行中")
|
||||
connected := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 8 { // the first interface row has five columns
|
||||
continue
|
||||
}
|
||||
p, known := byKey[fields[0]]
|
||||
if !known {
|
||||
// Preserve visibility for a manually added peer instead of silently
|
||||
// attributing it to a configured client.
|
||||
p = peer{ID: "unknown-" + fields[0][:min(8, len(fields[0]))], PublicKey: fields[0], Enabled: true}
|
||||
}
|
||||
handshake, _ := strconv.ParseInt(fields[4], 10, 64)
|
||||
rx, _ := strconv.ParseInt(fields[5], 10, 64)
|
||||
tx, _ := strconv.ParseInt(fields[6], 10, 64)
|
||||
alive := handshake > 0 && time.Since(time.Unix(handshake, 0)) <= handshakeAlive
|
||||
if alive {
|
||||
connected++
|
||||
}
|
||||
st.RxBytes += rx
|
||||
st.TxBytes += tx
|
||||
st.Peers = append(st.Peers, core.PeerStatus{
|
||||
ID: p.ID, Name: p.Name, PublicKey: fields[0], Endpoint: fields[2], AllowedIPs: fields[3],
|
||||
LatestHandshakeAt: handshake, Connected: alive, RxBytes: rx, TxBytes: tx,
|
||||
})
|
||||
}
|
||||
sort.Slice(st.Peers, func(i, j int) bool { return st.Peers[i].ID < st.Peers[j].ID })
|
||||
st.Detail = fmt.Sprintf("WireGuard 接口 %s 运行中,%d/%d 个 Peer 最近已握手", interfaceName(t), connected, len(st.Peers))
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// interfaceName is deterministic, <= IFNAMSIZ-1 (15) and does not depend on
|
||||
// the user supplied tunnel name. This prevents cross-tunnel interface clashes.
|
||||
func interfaceName(t *model.Tunnel) string { return "wg" + strconv.FormatUint(uint64(t.ID), 36) }
|
||||
|
||||
func allocatePort(tunnelID uint) (int, error) {
|
||||
for port := defaultPort; port <= maxListenPort; port++ {
|
||||
if ensurePortAvailable(tunnelID, port) == nil {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("没有可用的 WireGuard UDP 端口(%d-%d)", defaultPort, maxListenPort)
|
||||
}
|
||||
|
||||
func ensurePortAvailable(tunnelID uint, port int) error {
|
||||
var tunnels []model.Tunnel
|
||||
if store.DB == nil {
|
||||
return nil
|
||||
}
|
||||
if err := store.DB.Where("protocol = ? AND id <> ?", "wireguard", tunnelID).Find(&tunnels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, other := range tunnels {
|
||||
cfg, err := store.TunnelConfig(&other)
|
||||
if err == nil && int(service.Num(cfg, "listen_port", defaultPort)) == port {
|
||||
return fmt.Errorf("WireGuard UDP 端口 %d 已被专线 %q 占用", port, other.Name)
|
||||
}
|
||||
}
|
||||
if !hostUDPPortAvailable(port) {
|
||||
return fmt.Errorf("WireGuard UDP 端口 %d 已被本机其他服务占用", port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostUDPPortAvailable(port int) bool {
|
||||
listener, err := net.ListenUDP("udp4", &net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return listener.Close() == nil
|
||||
}
|
||||
|
||||
func buildPeers(cfg, secrets map[string]interface{}) ([]peer, map[string]peerSecret, error) {
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Compatibility migration: old one-client configs become client-1.
|
||||
if len(peers) == 0 {
|
||||
address := service.StrOr(cfg, "client_address", "10.8.0.2/32")
|
||||
if !validCIDR(address) {
|
||||
return nil, nil, fmt.Errorf("client_address 必须是合法 CIDR")
|
||||
}
|
||||
peers = []peer{{ID: "client-1", Name: "客户端 1", PublicKey: service.Str(cfg, "client_public_key"), ClientAddress: address, AllowedIPs: service.Str(cfg, "allowed_ips"), Enabled: true, Managed: true}}
|
||||
}
|
||||
oldSecrets := parsePeerSecrets(secrets)
|
||||
if legacyPrivate := service.Str(secrets, "client_private_key"); legacyPrivate != "" {
|
||||
oldSecrets["client-1"] = peerSecret{PrivateKey: legacyPrivate, PresharedKey: service.Str(secrets, "preshared_key")}
|
||||
}
|
||||
seenIDs, seenKeys, seenAddresses := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for i := range peers {
|
||||
p := &peers[i]
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("client-%d", i+1)
|
||||
}
|
||||
if !peerIDPattern.MatchString(p.ID) {
|
||||
return nil, nil, fmt.Errorf("Peer ID %q 包含非法字符", p.ID)
|
||||
}
|
||||
if seenIDs[p.ID] {
|
||||
return nil, nil, fmt.Errorf("Peer ID 重复: %s", p.ID)
|
||||
}
|
||||
seenIDs[p.ID] = true
|
||||
if p.ClientAddress == "" || !validCIDR(p.ClientAddress) {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 client_address 必须是合法 CIDR", p.ID)
|
||||
}
|
||||
if seenAddresses[p.ClientAddress] {
|
||||
return nil, nil, fmt.Errorf("Peer client_address 重复: %s", p.ClientAddress)
|
||||
}
|
||||
seenAddresses[p.ClientAddress] = true
|
||||
if p.AllowedIPs == "" {
|
||||
p.AllowedIPs = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
if err := validateCIDRList(p.AllowedIPs); err != nil {
|
||||
return nil, nil, fmt.Errorf("Peer %s allowed_ips: %w", p.ID, err)
|
||||
}
|
||||
if strings.ContainsAny(p.Name, "\r\n") {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 name 不能包含换行符", p.ID)
|
||||
}
|
||||
sec := oldSecrets[p.ID]
|
||||
// An unchanged public key with a stored private key is still a platform
|
||||
// managed peer even if a client omitted the derived `managed` flag when
|
||||
// submitting an update. A changed public key deliberately becomes an
|
||||
// external peer and must not retain the old device private key/PSK.
|
||||
if p.PublicKey != "" && sec.PrivateKey != "" {
|
||||
existingPublic, err := publicKey(sec.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Peer %s: %w", p.ID, err)
|
||||
}
|
||||
if existingPublic == p.PublicKey {
|
||||
p.Managed = true
|
||||
} else {
|
||||
p.Managed = false
|
||||
sec.PrivateKey = ""
|
||||
sec.PresharedKey = ""
|
||||
}
|
||||
}
|
||||
if p.PublicKey == "" {
|
||||
if sec.PrivateKey != "" {
|
||||
pub, err := publicKey(sec.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Peer %s: %w", p.ID, err)
|
||||
}
|
||||
p.PublicKey, p.Managed = pub, true
|
||||
} else {
|
||||
priv, pub, err := genKey()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p.PublicKey, sec.PrivateKey, p.Managed = pub, priv, true
|
||||
}
|
||||
}
|
||||
if !isWireGuardKey(p.PublicKey) {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 public_key 不是 WireGuard 公钥", p.ID)
|
||||
}
|
||||
if seenKeys[p.PublicKey] {
|
||||
return nil, nil, fmt.Errorf("Peer public_key 重复: %s", p.ID)
|
||||
}
|
||||
seenKeys[p.PublicKey] = true
|
||||
if sec.PresharedKey == "" {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sec.PresharedKey = base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
if !isWireGuardKey(sec.PresharedKey) {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 preshared_key 格式非法", p.ID)
|
||||
}
|
||||
oldSecrets[p.ID] = sec
|
||||
}
|
||||
return peers, oldSecrets, nil
|
||||
}
|
||||
|
||||
func parsePeers(cfg map[string]interface{}) ([]peer, error) {
|
||||
raw, exists := cfg["peers"]
|
||||
if !exists || raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
// JSON retrieved from store uses []interface{}, but accepting []map makes
|
||||
// service-level callers and tests practical as well.
|
||||
if maps, mapsOK := raw.([]map[string]interface{}); mapsOK {
|
||||
items = make([]interface{}, len(maps))
|
||||
for i := range maps {
|
||||
items[i] = maps[i]
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("peers 必须是数组")
|
||||
}
|
||||
}
|
||||
peers := make([]peer, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("peers 项必须是对象")
|
||||
}
|
||||
p := peer{ID: service.Str(m, "id"), Name: service.Str(m, "name"), PublicKey: service.Str(m, "public_key"), ClientAddress: service.Str(m, "client_address"), AllowedIPs: service.Str(m, "allowed_ips"), Managed: service.Bool(m, "managed", false), Enabled: true}
|
||||
if _, set := m["enabled"]; set {
|
||||
p.Enabled = service.Bool(m, "enabled", true)
|
||||
}
|
||||
peers = append(peers, p)
|
||||
}
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func validatePeerInput(peers []peer) error {
|
||||
seenIDs, seenKeys, seenAddresses := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for i := range peers {
|
||||
p := peers[i]
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("client-%d", i+1)
|
||||
}
|
||||
if !peerIDPattern.MatchString(p.ID) {
|
||||
return fmt.Errorf("Peer ID %q 包含非法字符", p.ID)
|
||||
}
|
||||
if seenIDs[p.ID] {
|
||||
return fmt.Errorf("Peer ID 重复: %s", p.ID)
|
||||
}
|
||||
seenIDs[p.ID] = true
|
||||
if !validCIDR(p.ClientAddress) {
|
||||
return fmt.Errorf("Peer %s 的 client_address 必须是合法 CIDR", p.ID)
|
||||
}
|
||||
if seenAddresses[p.ClientAddress] {
|
||||
return fmt.Errorf("Peer client_address 重复: %s", p.ClientAddress)
|
||||
}
|
||||
seenAddresses[p.ClientAddress] = true
|
||||
if p.PublicKey != "" {
|
||||
if !isWireGuardKey(p.PublicKey) {
|
||||
return fmt.Errorf("Peer %s 的 public_key 不是 WireGuard 公钥", p.ID)
|
||||
}
|
||||
if seenKeys[p.PublicKey] {
|
||||
return fmt.Errorf("Peer public_key 重复: %s", p.ID)
|
||||
}
|
||||
seenKeys[p.PublicKey] = true
|
||||
}
|
||||
if p.AllowedIPs != "" {
|
||||
if err := validateCIDRList(p.AllowedIPs); err != nil {
|
||||
return fmt.Errorf("Peer %s allowed_ips: %w", p.ID, err)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(p.Name, "\r\n") {
|
||||
return fmt.Errorf("Peer %s 的 name 不能包含换行符", p.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func peersToConfig(peers []peer) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
out = append(out, map[string]interface{}{"id": p.ID, "name": p.Name, "public_key": p.PublicKey, "client_address": p.ClientAddress, "allowed_ips": p.AllowedIPs, "enabled": p.Enabled, "managed": p.Managed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parsePeerSecrets(secrets map[string]interface{}) map[string]peerSecret {
|
||||
out := map[string]peerSecret{}
|
||||
raw, ok := secrets["peer_secrets"].(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for id, value := range raw {
|
||||
m, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[id] = peerSecret{PrivateKey: service.Str(m, "private_key"), PresharedKey: service.Str(m, "preshared_key")}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func peerSecretsToConfig(secrets map[string]peerSecret) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(secrets))
|
||||
for id, sec := range secrets {
|
||||
out[id] = map[string]interface{}{"private_key": sec.PrivateKey, "preshared_key": sec.PresharedKey}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderServerConfig(cfg map[string]interface{}, serverPrivate string, peers []peer, secrets map[string]peerSecret) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s\nListenPort = %d\n", serverPrivate, service.Str(cfg, "address"), int(service.Num(cfg, "listen_port", defaultPort)))
|
||||
if service.Bool(cfg, "enable_nat", false) {
|
||||
egress := service.Str(cfg, "egress_interface")
|
||||
fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n", egress)
|
||||
fmt.Fprintf(&b, "PostDown = iptables -D FORWARD -i %%i -j ACCEPT; iptables -D FORWARD -o %%i -j ACCEPT; iptables -t nat -D POSTROUTING -o %s -j MASQUERADE\n", egress)
|
||||
}
|
||||
for _, p := range peers {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[Peer]\n# %s (%s)\nPublicKey = %s\nPresharedKey = %s\nAllowedIPs = %s\n", p.Name, p.ID, p.PublicKey, secrets[p.ID].PresharedKey, p.ClientAddress)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func validCIDR(value string) bool {
|
||||
_, _, err := net.ParseCIDR(strings.TrimSpace(value))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validateCIDRList(value string) error {
|
||||
for _, entry := range strings.Split(value, ",") {
|
||||
if !validCIDR(entry) {
|
||||
return fmt.Errorf("%q 不是 CIDR", strings.TrimSpace(entry))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestBuildPeersMigratesLegacySingleClient(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"address": "10.8.0.1/24",
|
||||
"client_address": "10.8.0.2/32",
|
||||
"allowed_ips": "0.0.0.0/0, ::/0",
|
||||
}
|
||||
peers, secrets, err := buildPeers(cfg, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildPeers: %v", err)
|
||||
}
|
||||
if len(peers) != 1 || peers[0].ID != "client-1" || !peers[0].Managed {
|
||||
t.Fatalf("legacy peer migration = %#v", peers)
|
||||
}
|
||||
if !isWireGuardKey(peers[0].PublicKey) || !isWireGuardKey(secrets["client-1"].PrivateKey) || !isWireGuardKey(secrets["client-1"].PresharedKey) {
|
||||
t.Fatal("generated peer keys are not WireGuard keys")
|
||||
}
|
||||
cfg["server_public_key"] = "server-public-key"
|
||||
cfg["listen_port"] = 51820
|
||||
client, err := generateClientConfig(cfg, peers, secrets, "client-1")
|
||||
if err != nil {
|
||||
t.Fatalf("generateClientConfig: %v", err)
|
||||
}
|
||||
if !strings.Contains(client, "Address = 10.8.0.2/32") || !strings.Contains(client, "Endpoint = your-server-ip:51820") {
|
||||
t.Fatalf("unexpected client config:\n%s", client)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPeersKeepsExternalPeerPrivateKeyOutOfPlatform(t *testing.T) {
|
||||
_, externalPublic, err := genKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := map[string]interface{}{
|
||||
"allowed_ips": "10.0.0.0/8",
|
||||
"peers": []interface{}{map[string]interface{}{
|
||||
"id": "branch-office",
|
||||
"name": "Branch office",
|
||||
"public_key": externalPublic,
|
||||
"client_address": "10.8.0.10/32",
|
||||
"enabled": true,
|
||||
}},
|
||||
}
|
||||
peers, secrets, err := buildPeers(cfg, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildPeers: %v", err)
|
||||
}
|
||||
if peers[0].Managed || secrets["branch-office"].PrivateKey != "" {
|
||||
t.Fatalf("external peer must not gain a platform-held private key: %#v %#v", peers[0], secrets["branch-office"])
|
||||
}
|
||||
if _, err := generateClientConfig(cfg, peers, secrets, "branch-office"); err == nil {
|
||||
t.Fatal("external peer configuration should not be downloadable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) {
|
||||
s := New()
|
||||
if _, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
|
||||
t.Fatal("NAT without explicit egress interface was accepted")
|
||||
}
|
||||
_, err := s.Validate(map[string]interface{}{
|
||||
"address": "10.8.0.1/24",
|
||||
"peers": []interface{}{map[string]interface{}{"id": "bad", "client_address": "not-a-cidr"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("invalid peer address was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNameFitsLinuxLimit(t *testing.T) {
|
||||
name := interfaceName(&model.Tunnel{ID: ^uint(0)})
|
||||
if len(name) > 15 {
|
||||
t.Fatalf("interface name %q exceeds IFNAMSIZ-1", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePersistsOnlyPublicPeerData(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", dataDir)
|
||||
if err := store.Init(dataDir); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
tunnel := &model.Tunnel{
|
||||
Name: "wg-test",
|
||||
Protocol: "wireguard",
|
||||
Config: `{"address":"10.60.0.1/24","client_address":"10.60.0.2/32"}`,
|
||||
}
|
||||
if err := store.DB.Create(tunnel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := New().Create(tunnel); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if strings.Contains(tunnel.Config, "private_key") || strings.Contains(tunnel.Config, "preshared_key") {
|
||||
t.Fatalf("private material leaked into Config: %s", tunnel.Config)
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tunnel.Config), &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config["interface_name"] != interfaceName(tunnel) || int(config["listen_port"].(float64)) < defaultPort {
|
||||
t.Fatalf("missing interface/allocated port: %#v", config)
|
||||
}
|
||||
conf, err := New().GenerateClientConfig(tunnel)
|
||||
if err != nil || !strings.Contains(conf, "PrivateKey = ") {
|
||||
t.Fatalf("client configuration unavailable: %v\n%s", err, conf)
|
||||
}
|
||||
if _, err := os.Stat(serviceConfigPath(tunnel)); err != nil {
|
||||
t.Fatalf("server config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serviceConfigPath(tunnel *model.Tunnel) string {
|
||||
return os.Getenv("VPN_DATA_DIR") + "/instances/" + strconv.Itoa(int(tunnel.ID)) + "-" + tunnel.Protocol + "/" + interfaceName(tunnel) + ".conf"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Package store 负责 SQLite 初始化、敏感字段 AES-GCM 加密存储
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
var gcm cipher.AEAD
|
||||
|
||||
// Init 打开数据库、迁移、初始化加密密钥与默认账号
|
||||
func Init(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "vpn-platform.db")), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}); err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
|
||||
if err := initKey(dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 默认管理员 admin / admin123(首次登录后请修改) // Logan.Li
|
||||
var count int64
|
||||
DB.Model(&model.User{}).Count(&count)
|
||||
if count == 0 {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
DB.Create(&model.User{Username: "admin", Password: string(hash), Role: "admin", Source: "local", Enabled: true})
|
||||
} else {
|
||||
// 兼容早期单管理员数据:管理员升级后保留完整管理权限。
|
||||
DB.Model(&model.User{}).Where("username = ? AND source = ?", "admin", "local").Update("role", "admin")
|
||||
}
|
||||
// 内置本机节点
|
||||
var nodeCount int64
|
||||
DB.Model(&model.Node{}).Count(&nodeCount)
|
||||
if nodeCount == 0 {
|
||||
DB.Create(&model.Node{Name: "本机节点", Address: "127.0.0.1", Kind: "local", Remark: "平台所在服务器"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initKey(dataDir string) error {
|
||||
keyFile := filepath.Join(dataDir, "secret.key")
|
||||
raw, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return err
|
||||
}
|
||||
raw = []byte(hex.EncodeToString(key))
|
||||
if err := os.WriteFile(keyFile, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
keyBytes, err := hex.DecodeString(string(raw))
|
||||
if err != nil || len(keyBytes) != 32 {
|
||||
sum := sha256.Sum256(raw)
|
||||
keyBytes = sum[:]
|
||||
}
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm = g
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptSecret 加密敏感字符串
|
||||
func EncryptSecret(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// DecryptSecret 解密敏感字符串
|
||||
func DecryptSecret(enc string) (string, error) {
|
||||
if enc == "" {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("密文长度非法")
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// Audit 写入操作审计
|
||||
func Audit(username, action, target, detail string) {
|
||||
if DB != nil {
|
||||
DB.Create(&model.AuditLog{Username: username, Action: action, Target: target, Detail: detail})
|
||||
}
|
||||
}
|
||||
|
||||
// GetTunnel 按 ID 查询专线
|
||||
func GetTunnel(id uint) (*model.Tunnel, error) {
|
||||
var t model.Tunnel
|
||||
if err := DB.Preload("Node").First(&t, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// TunnelConfig 解析专线 Config JSON
|
||||
func TunnelConfig(t *model.Tunnel) (map[string]interface{}, error) {
|
||||
cfg := map[string]interface{}{}
|
||||
if t.Config != "" {
|
||||
if err := json.Unmarshal([]byte(t.Config), &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// TunnelSecrets 解析并解密敏感字段
|
||||
func TunnelSecrets(t *model.Tunnel) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
plain, err := DecryptSecret(t.Secrets)
|
||||
if err != nil || plain == "" {
|
||||
return out, nil
|
||||
}
|
||||
_ = json.Unmarshal([]byte(plain), &out)
|
||||
return out, nil
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// VPN 管理平台 —— 单二进制交付(前端静态资源内嵌) // Logan.Li
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/api"
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/monitor"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/service/custom"
|
||||
"vpn-platform/backend/internal/service/frp"
|
||||
"vpn-platform/backend/internal/service/ipsec"
|
||||
"vpn-platform/backend/internal/service/l2tp"
|
||||
"vpn-platform/backend/internal/service/openvpn"
|
||||
"vpn-platform/backend/internal/service/wireguard"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
// 数据目录:默认 ./data,可用 VPN_DATA_DIR 覆盖
|
||||
dataDir := service.BaseDir()
|
||||
if err := store.Init(dataDir); err != nil {
|
||||
log.Fatalf("初始化存储失败: %v", err)
|
||||
}
|
||||
// OpenVPN invokes this same, already-installed binary from its
|
||||
// auth-user-pass-verify hook. Keep it before HTTP startup so the hook has a
|
||||
// short-lived process and a meaningful exit status.
|
||||
if len(os.Args) > 1 && os.Args[1] == "openvpn-auth" {
|
||||
if err := openvpn.RunAuthHelper(os.Args[2:]); err != nil {
|
||||
log.Printf("OpenVPN 用户认证失败: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 注册内置协议
|
||||
mgr := core.NewManager()
|
||||
mgr.Register(wireguard.New())
|
||||
mgr.Register(ipsec.New())
|
||||
mgr.Register(l2tp.New())
|
||||
mgr.Register(frp.New())
|
||||
mgr.Register(openvpn.New())
|
||||
// 加载数据库中的自定义协议插件
|
||||
custom.LoadAll(mgr)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
srv := api.NewServer(mgr)
|
||||
srv.RegisterRoutes(r)
|
||||
|
||||
// 内嵌前端(SPA 回退到 index.html)
|
||||
dist, err := fs.Sub(webFS, "dist")
|
||||
if err != nil {
|
||||
log.Fatalf("内嵌资源损坏: %v", err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(dist))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if f, err := dist.Open(path); err == nil {
|
||||
f.Close()
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
c.Request.URL.Path = "/"
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
// 状态采集 + 自启恢复
|
||||
monitor.Start(mgr, 15*time.Second)
|
||||
monitor.AutoStartAll(mgr)
|
||||
|
||||
addr := os.Getenv("VPN_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
log.Printf("VPN 管理平台已启动: http://localhost%s (默认账号 admin / admin123)", addr)
|
||||
log.Printf("协议依赖检测:")
|
||||
for _, p := range mgr.List() {
|
||||
ok, dep := p.Available()
|
||||
mark := "✓"
|
||||
if !ok {
|
||||
mark = "✗"
|
||||
}
|
||||
log.Printf(" [%s] %-10s %s", mark, p.Name(), dep)
|
||||
}
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VPN 专线管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2085
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "vpn-platform-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.7",
|
||||
"echarts": "^5.5.1",
|
||||
"element-plus": "^2.8.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
import axios from 'axios'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// 极简全局状态
|
||||
export const auth = reactive({
|
||||
token: localStorage.getItem('vpn_token') || '',
|
||||
username: localStorage.getItem('vpn_user') || '',
|
||||
login(token, username) {
|
||||
this.token = token
|
||||
this.username = username
|
||||
localStorage.setItem('vpn_token', token)
|
||||
localStorage.setItem('vpn_user', username)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.username = ''
|
||||
localStorage.removeItem('vpn_token')
|
||||
localStorage.removeItem('vpn_user')
|
||||
}
|
||||
})
|
||||
|
||||
const api = axios.create({ baseURL: '/api', timeout: 30000 })
|
||||
|
||||
api.interceptors.request.use(cfg => {
|
||||
if (auth.token) cfg.headers.Authorization = `Bearer ${auth.token}`
|
||||
return cfg
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,117 @@
|
||||
// 各协议的创建向导表单定义(前端动态渲染)
|
||||
export const PROTOCOL_META = {
|
||||
wireguard: {
|
||||
name: 'WireGuard',
|
||||
desc: '性能最好的现代 VPN,内核级实现,漫游友好',
|
||||
color: '#88171a',
|
||||
icon: '⚡',
|
||||
scenes: '内部接入 / 移动办公',
|
||||
fields: [
|
||||
{ key: 'address', label: '服务端 VPN 网段地址', placeholder: '10.8.0.1/24', required: true, tip: '服务端在 VPN 网段中的地址(CIDR)' },
|
||||
{ key: 'listen_port', label: '监听端口', type: 'number', default: 51820 },
|
||||
{ key: 'client_address', label: '客户端地址', placeholder: '10.8.0.2/32', tip: '分配给首个客户端的地址' },
|
||||
{ key: 'public_endpoint', label: '公网接入点', placeholder: 'vpn.example.com:51820', tip: '客户端连接用,公网 IP:端口' },
|
||||
{ key: 'dns', label: 'DNS', default: '223.5.5.5' },
|
||||
{ key: 'allowed_ips', label: '客户端 AllowedIPs', default: '0.0.0.0/0, ::/0', tip: '全流量代理用 0.0.0.0/0;仅内网填内网网段' }
|
||||
]
|
||||
},
|
||||
ipsec: {
|
||||
name: 'IPSec 站点互联',
|
||||
desc: '与第三方设备(Cisco/华为/飞塔等)做站点到站点对接',
|
||||
color: '#1f6fb2',
|
||||
icon: '🔗',
|
||||
scenes: '第三方对接 / 站点互联',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '本端公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'remote_gateway', label: '对端网关地址', placeholder: '5.6.7.8', required: true },
|
||||
{ key: 'local_subnets', label: '本端网段', placeholder: '192.168.1.0/24', required: true, tip: '多个网段用逗号分隔' },
|
||||
{ key: 'remote_subnets', label: '对端网段', placeholder: '10.0.0.0/24', required: true },
|
||||
{ key: 'psk', label: '预共享密钥 PSK', type: 'password', required: true, secret: true },
|
||||
{ key: 'ike_version', label: 'IKE 版本', type: 'select', options: ['ikev2', 'ikev1'], default: 'ikev2' },
|
||||
{ key: 'ike_proposal', label: 'Phase1 提议', placeholder: 'aes256-sha256-modp2048', tip: '加密-认证-DH组,需与对端一致' },
|
||||
{ key: 'esp_proposal', label: 'Phase2 提议', placeholder: 'aes256-sha256' },
|
||||
{ key: 'local_id', label: '本端标识 ID', placeholder: '@vpn-local' },
|
||||
{ key: 'remote_id', label: '对端标识 ID', placeholder: '@vpn-remote' }
|
||||
]
|
||||
},
|
||||
l2tp: {
|
||||
name: 'L2TP/IPSec',
|
||||
desc: '兼容性最好,手机/老设备/路由器原生支持',
|
||||
color: '#5a8a3c',
|
||||
icon: '📱',
|
||||
scenes: '移动设备 / 老设备兼容',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '服务器公网 IP', placeholder: '1.2.3.4', required: true },
|
||||
{ key: 'psk', label: 'IPSec 预共享密钥', type: 'password', required: true, secret: true },
|
||||
{ key: 'username', label: '拨号用户名', default: 'vpnuser' },
|
||||
{ key: 'password', label: '拨号密码', type: 'password', required: true, secret: true },
|
||||
{ key: 'ip_range', label: '客户端地址池', default: '10.9.0.100-10.9.0.200' },
|
||||
{ key: 'local_ip', label: '服务端隧道地址', default: '10.9.0.1' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
]
|
||||
},
|
||||
frp: {
|
||||
name: 'FRP 内网穿透',
|
||||
desc: '国内刚需,TCP/UDP/HTTP 端口映射与反向代理',
|
||||
color: '#c0762b',
|
||||
icon: '🚇',
|
||||
scenes: '内网穿透 / 端口映射',
|
||||
fields: [
|
||||
{ key: 'mode', label: '运行模式', type: 'select', options: ['server', 'client'], default: 'server', tip: 'server=frps(有公网),client=frpc(内网侧)' },
|
||||
{ key: 'bind_port', label: '绑定端口(server)', type: 'number', default: 7000, showIf: { mode: 'server' } },
|
||||
{ key: 'public_endpoint', label: '公网地址(server)', placeholder: '1.2.3.4', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_port', label: '管理面板端口(server)', type: 'number', default: 7500, showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_user', label: '管理面板账号(server)', default: 'admin', showIf: { mode: 'server' } },
|
||||
{ key: 'dashboard_password', label: '管理面板密码(server)', type: 'password', secret: true, showIf: { mode: 'server' }, tip: '留空时自动生成强随机密码;编辑时留空保持原密码' },
|
||||
{ key: 'server_addr', label: 'frps 地址(client)', placeholder: '1.2.3.4', showIf: { mode: 'client' } },
|
||||
{ key: 'server_port', label: 'frps 端口(client)', type: 'number', default: 7000, showIf: { mode: 'client' } },
|
||||
{ key: 'token', label: '认证 Token', type: 'password', secret: true, tip: '留空自动生成' },
|
||||
{ key: 'proxy_name', label: '代理名称', placeholder: 'ssh' },
|
||||
{ key: 'proxy_type', label: '代理类型', type: 'select', options: ['tcp', 'udp', 'http', 'https'], default: 'tcp' },
|
||||
{ key: 'local_ip', label: '内网目标 IP', default: '127.0.0.1' },
|
||||
{ key: 'local_port', label: '内网目标端口', type: 'number', default: 22 },
|
||||
{ key: 'remote_port', label: '公网映射端口', type: 'number', default: 6000, showIf: { proxy_type: ['tcp', 'udp'] } },
|
||||
{ key: 'custom_domains', label: '绑定域名', placeholder: 'app.example.com, www.example.com', showIf: { proxy_type: ['http', 'https'] }, tip: 'HTTP/HTTPS 代理必填,多个域名用逗号分隔' }
|
||||
]
|
||||
},
|
||||
openvpn: {
|
||||
name: 'OpenVPN',
|
||||
desc: '最成熟的开源 VPN,兼容各类老项目与系统',
|
||||
color: '#e07b39',
|
||||
icon: '🛡️',
|
||||
scenes: '老项目兼容 / 通用接入',
|
||||
fields: [
|
||||
{ key: 'public_endpoint', label: '公网接入地址', placeholder: 'vpn.example.com', required: true },
|
||||
{ key: 'port', label: '端口', type: 'number', default: 1194 },
|
||||
{ key: 'proto', label: '传输协议', type: 'select', options: ['udp', 'tcp'], default: 'udp' },
|
||||
{ key: 'subnet', label: 'VPN 网段', default: '10.10.0.0 255.255.255.0' },
|
||||
{ key: 'cipher', label: '加密算法', type: 'select', options: ['AES-256-GCM', 'AES-128-GCM', 'AES-256-CBC'], default: 'AES-256-GCM' },
|
||||
{ key: 'dns', label: '下发 DNS', default: '223.5.5.5' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义协议插件的默认表单(插件参数完全自由)
|
||||
export const CUSTOM_FIELDS = [
|
||||
{ key: 'endpoint', label: '接入地址', placeholder: 'host:port' },
|
||||
{ key: 'token', label: '认证凭据', type: 'password', secret: true },
|
||||
{ key: 'extra', label: '扩展参数(JSON)', placeholder: '{"key":"value"}' }
|
||||
]
|
||||
|
||||
export const PROTOCOL_COLORS = {
|
||||
wireguard: '#88171a',
|
||||
ipsec: '#1f6fb2',
|
||||
l2tp: '#5a8a3c',
|
||||
frp: '#c0762b',
|
||||
openvpn: '#e07b39'
|
||||
}
|
||||
|
||||
export function protocolDisplay(t) {
|
||||
if (t.startsWith('custom:')) return t.slice(7)
|
||||
return PROTOCOL_META[t]?.name || t
|
||||
}
|
||||
|
||||
export function protocolColor(t) {
|
||||
if (t.startsWith('custom:')) return '#7b5ea7'
|
||||
return PROTOCOL_COLORS[t] || '#606266'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { auth } from './api'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('./views/Login.vue'), meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('./views/Layout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { title: '总览' } },
|
||||
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { title: '专线列表' } },
|
||||
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { title: '创建专线' } },
|
||||
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { title: '编辑专线' } },
|
||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { title: 'IPSec 第三方对接' } },
|
||||
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { title: '节点管理' } },
|
||||
{ path: 'users', component: () => import('./views/Users.vue'), meta: { title: '用户与 LDAP' } },
|
||||
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { title: 'VPN 拨号用户' } },
|
||||
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { title: '协议插件' } },
|
||||
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
router.beforeEach(to => {
|
||||
if (!to.meta.public && !auth.token) return '/login'
|
||||
if (to.path === '/login' && auth.token) return '/'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,13 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; margin: 0; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; }
|
||||
body { background: #f5f7fa; }
|
||||
|
||||
.stat-card { border-radius: 10px; padding: 20px; color: #fff; position: relative; overflow: hidden; }
|
||||
.stat-card .num { font-size: 32px; font-weight: 700; margin-top: 8px; }
|
||||
.stat-card .label { opacity: .85; font-size: 14px; }
|
||||
|
||||
.page-card { border-radius: 10px; }
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.protocol-tag { font-weight: 600; }
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#4a6fa5,#2b4a7a)">
|
||||
<div class="label">专线总数</div><div class="num">{{ ov.total ?? '-' }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#4caf7d,#2e7d5b)">
|
||||
<div class="label">运行中</div><div class="num">{{ ov.running ?? '-' }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#e0935a,#c0762b)">
|
||||
<div class="label">节点 / 插件</div><div class="num">{{ ov.nodes ?? 0 }} / {{ ov.plugins ?? 0 }}</div></div></el-col>
|
||||
<el-col :span="6"><div class="stat-card" style="background:linear-gradient(135deg,#7b5ea7,#5a4080)">
|
||||
<div class="label">累计流量 ↓↑</div><div class="num" style="font-size:22px">{{ fmtBytes(ov.rx_bytes) }} / {{ fmtBytes(ov.tx_bytes) }}</div></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="10">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>协议分布</b></template>
|
||||
<div ref="pieRef" style="height:280px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>协议引擎状态</b> <span class="dep-hint">系统依赖检测结果</span></template>
|
||||
<el-table :data="protocols" size="large">
|
||||
<el-table-column label="协议" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="protocol-tag" :style="{ color: protocolColor(row.name) }">{{ row.display_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="依赖状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.available ? 'success' : 'warning'" size="small">
|
||||
{{ row.available ? '已就绪' : '未部署' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dependency" label="说明" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="ov.expiring?.length">
|
||||
<template #header><b style="color:#e6a23c">⚠ 即将到期的专线(7 天内)</b></template>
|
||||
<el-table :data="ov.expiring" size="small">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="协议">
|
||||
<template #default="{ row }">{{ protocolDisplay(row.protocol) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间">
|
||||
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import api from '../api'
|
||||
import { protocolColor, protocolDisplay } from '../protocols'
|
||||
|
||||
const ov = ref({})
|
||||
const protocols = ref([])
|
||||
const pieRef = ref(null)
|
||||
let chart = null
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function load() {
|
||||
const [{ data: o }, { data: p }] = await Promise.all([api.get('/overview'), api.get('/protocols')])
|
||||
ov.value = o
|
||||
protocols.value = p
|
||||
renderPie()
|
||||
}
|
||||
|
||||
function renderPie() {
|
||||
if (!pieRef.value) return
|
||||
if (!chart) chart = echarts.init(pieRef.value)
|
||||
const dist = (ov.value.protocol_distribution || []).map(d => ({
|
||||
name: protocolDisplay(d.Protocol || d.protocol),
|
||||
value: d.Count || d.count,
|
||||
itemStyle: { color: protocolColor(d.Protocol || d.protocol) }
|
||||
}))
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['45%', '70%'], center: ['50%', '45%'],
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data: dist.length ? dist : [{ name: '暂无专线', value: 1, itemStyle: { color: '#e0e3e8' } }]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(() => { load(); timer = setInterval(load, 15000) })
|
||||
onUnmounted(() => { clearInterval(timer); chart?.dispose() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dep-hint { font-size: 12px; color: #909399; font-weight: normal; margin-left: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="16">
|
||||
<!-- 左:本端配置 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<b>本端 IPSec 参数</b>
|
||||
<el-select v-model="selectedTemplate" placeholder="载入设备模板" size="small" style="width:200px;float:right"
|
||||
@change="applyTemplate">
|
||||
<el-option v-for="t in templates" :key="t.key" :label="t.name" :value="t.key" />
|
||||
</el-select>
|
||||
</template>
|
||||
<el-alert v-if="templateNote" :title="templateNote" type="info" :closable="false" style="margin-bottom:14px" />
|
||||
<el-form label-width="130px" size="default">
|
||||
<el-form-item label="本端公网 IP" required><el-input v-model="local.public_endpoint" /></el-form-item>
|
||||
<el-form-item label="对端网关" required><el-input v-model="local.remote_gateway" /></el-form-item>
|
||||
<el-form-item label="本端网段" required><el-input v-model="local.local_subnets" placeholder="192.168.1.0/24" /></el-form-item>
|
||||
<el-form-item label="对端网段" required><el-input v-model="local.remote_subnets" placeholder="10.0.0.0/24" /></el-form-item>
|
||||
<el-form-item label="预共享密钥" required><el-input v-model="local.psk" type="password" show-password /></el-form-item>
|
||||
<el-divider content-position="left">Phase 1(IKE SA)</el-divider>
|
||||
<el-form-item label="IKE 版本">
|
||||
<el-radio-group v-model="local.ike_version">
|
||||
<el-radio-button value="ikev2">IKEv2</el-radio-button>
|
||||
<el-radio-button value="ikev1">IKEv1</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="提议">
|
||||
<el-input v-model="local.ike_proposal" placeholder="aes256-sha256-modp2048" />
|
||||
</el-form-item>
|
||||
<el-form-item label="本端 ID"><el-input v-model="local.local_id" placeholder="@vpn-local" /></el-form-item>
|
||||
<el-form-item label="对端 ID"><el-input v-model="local.remote_id" placeholder="@vpn-remote" /></el-form-item>
|
||||
<el-divider content-position="left">Phase 2(IPSec SA)</el-divider>
|
||||
<el-form-item label="ESP 提议"><el-input v-model="local.esp_proposal" placeholder="aes256-sha256" /></el-form-item>
|
||||
<el-form-item label="PFS 组"><el-input v-model="local.pfs_group" placeholder="modp2048" /></el-form-item>
|
||||
<el-form-item label="DPD 策略">
|
||||
<el-select v-model="local.dpd_action" style="width:100%">
|
||||
<el-option label="restart(断线重连)" value="restart" />
|
||||
<el-option label="clear(清除)" value="clear" />
|
||||
<el-option label="trap(按需触发)" value="trap" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" style="width:100%" :loading="creating" @click="createTunnel">
|
||||
创建 IPSec 专线
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:对端参数对比 + 连通性测试 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>双方参数一致性检查</b></template>
|
||||
<el-form label-width="130px" size="default">
|
||||
<el-form-item label="对端 IKE 版本">
|
||||
<el-radio-group v-model="peer.ike_version">
|
||||
<el-radio-button value="ikev2">IKEv2</el-radio-button>
|
||||
<el-radio-button value="ikev1">IKEv1</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="对端 Phase1"><el-input v-model="peer.ike_proposal" placeholder="对端的 IKE 提议" /></el-form-item>
|
||||
<el-form-item label="对端 Phase2"><el-input v-model="peer.esp_proposal" placeholder="对端的 ESP 提议" /></el-form-item>
|
||||
<el-form-item label="对端 PFS 组"><el-input v-model="peer.pfs_group" /></el-form-item>
|
||||
<el-form-item label="对端本端网段"><el-input v-model="peer.local_subnets" placeholder="对端视角的 local(=我方 remote)" /></el-form-item>
|
||||
</el-form>
|
||||
<el-button style="width:100%" :loading="checking" @click="compare">开始对比检查</el-button>
|
||||
<div v-if="compareResult" style="margin-top:14px">
|
||||
<el-alert v-if="compareResult.consistent" type="success" title="✓ 双方参数一致,可以建立隧道" :closable="false" />
|
||||
<div v-else>
|
||||
<el-alert type="error" title="发现参数不一致:" :closable="false" />
|
||||
<ul class="diff-list">
|
||||
<li v-for="(d, i) in compareResult.differences" :key="i">{{ d }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>一键连通性测试</b></template>
|
||||
<el-select v-model="testTunnelId" placeholder="选择已有 IPSec 专线" style="width:100%">
|
||||
<el-option v-for="t in ipsecTunnels" :key="t.id" :label="`${t.name}(${t.state})`" :value="t.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" style="width:100%;margin-top:12px" :loading="testing" :disabled="!testTunnelId"
|
||||
@click="testConnectivity">测试与对端网关连通性</el-button>
|
||||
<div v-if="testResult" style="margin-top:14px">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="目标">{{ testResult.target }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果">
|
||||
<el-tag :type="testResult.reachable ? 'success' : 'danger'">
|
||||
{{ testResult.reachable ? '可达' : '不可达' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ testResult.latency_ms }} ms</el-descriptions-item>
|
||||
<el-descriptions-item label="说明">{{ testResult.note || testResult.ike_udp500_note || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../api'
|
||||
|
||||
const templates = ref([])
|
||||
const selectedTemplate = ref('')
|
||||
const templateNote = ref('')
|
||||
const local = ref({ ike_version: 'ikev2', dpd_action: 'restart' })
|
||||
const peer = ref({ ike_version: 'ikev2' })
|
||||
const compareResult = ref(null)
|
||||
const checking = ref(false)
|
||||
const creating = ref(false)
|
||||
const ipsecTunnels = ref([])
|
||||
const testTunnelId = ref(null)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
function applyTemplate(key) {
|
||||
const t = templates.value.find(x => x.key === key)
|
||||
if (!t) return
|
||||
local.value.ike_version = t.ike_version
|
||||
local.value.ike_proposal = t.ike_proposal
|
||||
local.value.esp_proposal = t.esp_proposal
|
||||
local.value.dpd_action = t.dpd_action
|
||||
templateNote.value = `已载入「${t.name}」模板:${t.note}`
|
||||
}
|
||||
|
||||
async function createTunnel() {
|
||||
const l = local.value
|
||||
if (!l.remote_gateway || !l.local_subnets || !l.remote_subnets || !l.psk) {
|
||||
return ElMessage.warning('请填写完整的必填项(网关/网段/PSK)')
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
await api.post('/tunnels', {
|
||||
name: `IPSec-${l.remote_gateway}`,
|
||||
protocol: 'ipsec',
|
||||
auto_start: true,
|
||||
config: l
|
||||
})
|
||||
ElMessage.success('IPSec 专线已创建并启动')
|
||||
loadTunnels()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '创建失败')
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
|
||||
async function compare() {
|
||||
checking.value = true
|
||||
try {
|
||||
const { data } = await api.post('/ipsec/compare', { local: local.value, peer: peer.value })
|
||||
compareResult.value = data
|
||||
} finally { checking.value = false }
|
||||
}
|
||||
|
||||
async function loadTunnels() {
|
||||
const { data } = await api.get('/tunnels', { params: { protocol: 'ipsec' } })
|
||||
ipsecTunnels.value = data || []
|
||||
}
|
||||
|
||||
async function testConnectivity() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const { data } = await api.post(`/tunnels/${testTunnelId.value}/test`)
|
||||
testResult.value = data
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '测试失败')
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/ipsec/templates')
|
||||
templates.value = data
|
||||
loadTunnels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.diff-list { margin: 10px 0 0; padding-left: 20px; color: #f56c6c; font-size: 13px; }
|
||||
.diff-list li { margin-bottom: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">🔐</span>
|
||||
<span class="logo-text">VPN 管理平台</span>
|
||||
</div>
|
||||
<el-menu :default-active="$route.path" router background-color="#1a2a4a" text-color="#a8b8d8"
|
||||
active-text-color="#ffffff" class="menu">
|
||||
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>总览</el-menu-item>
|
||||
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>专线列表</el-menu-item>
|
||||
<el-menu-item index="/tunnels/create"><el-icon><CirclePlus /></el-icon>创建专线</el-menu-item>
|
||||
<el-menu-item index="/ipsec-peer"><el-icon><Link /></el-icon>IPSec 对接</el-menu-item>
|
||||
<el-menu-item index="/nodes"><el-icon><Share /></el-icon>节点管理</el-menu-item>
|
||||
<el-menu-item index="/users"><el-icon><UserFilled /></el-icon>用户与 LDAP</el-menu-item>
|
||||
<el-menu-item index="/vpn-users"><el-icon><User /></el-icon>VPN 拨号用户</el-menu-item>
|
||||
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>协议插件</el-menu-item>
|
||||
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>系统设置</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<span class="page-title">{{ $route.meta.title }}</span>
|
||||
<el-dropdown @command="onCommand">
|
||||
<span class="user">
|
||||
<el-icon><Avatar /></el-icon> {{ auth.username }} <el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="password">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DataAnalysis, Connection, CirclePlus, Link, Share, Grid, Setting, Avatar, ArrowDown, UserFilled, User } from '@element-plus/icons-vue'
|
||||
import { auth } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
function onCommand(cmd) {
|
||||
if (cmd === 'logout') { auth.logout(); router.push('/login') }
|
||||
if (cmd === 'password') router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { height: 100vh; }
|
||||
.aside { background: #1a2a4a; }
|
||||
.logo { display: flex; align-items: center; gap: 10px; padding: 18px 20px; color: #fff; font-size: 17px; font-weight: 700; }
|
||||
.logo-icon { font-size: 22px; }
|
||||
.menu { border-right: none; }
|
||||
.header {
|
||||
background: #fff; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid #e8eaee; box-shadow: 0 1px 4px rgba(0,0,0,.04);
|
||||
}
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.user { cursor: pointer; color: #606266; display: flex; align-items: center; gap: 4px; }
|
||||
.main { background: #f5f7fa; padding: 20px; overflow-y: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="login-wrap">
|
||||
<div class="login-box">
|
||||
<div class="brand">
|
||||
<div class="logo">🔐</div>
|
||||
<h2>VPN 专线管理平台</h2>
|
||||
<p>WireGuard · IPSec · L2TP · FRP · OpenVPN</p>
|
||||
</div>
|
||||
<el-form @submit.prevent="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="username" placeholder="用户名" size="large" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="password" type="password" placeholder="密码" size="large" show-password
|
||||
:prefix-icon="Lock" @keyup.enter="doLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="mfaCode" placeholder="MFA 验证码(未启用可留空)" size="large" @keyup.enter="doLogin" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="hint">默认账号 admin / admin123,登录后请立即修改</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const mfaCode = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function doLogin() {
|
||||
if (!username.value || !password.value) return ElMessage.warning('请输入用户名和密码')
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/login', { username: username.value, password: password.value, mfa_code: mfaCode.value })
|
||||
auth.login(data.token, data.username)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap {
|
||||
height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #1a2a4a 0%, #2b5876 100%);
|
||||
}
|
||||
.login-box {
|
||||
width: 380px; background: #fff; border-radius: 14px; padding: 40px 36px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
}
|
||||
.brand { text-align: center; margin-bottom: 28px; }
|
||||
.logo { font-size: 44px; }
|
||||
.brand h2 { margin: 10px 0 6px; color: #1a2a4a; }
|
||||
.brand p { color: #909399; font-size: 13px; margin: 0; }
|
||||
.hint { text-align: center; color: #c0c4cc; font-size: 12px; margin-top: 18px; }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>中继节点</b>
|
||||
<el-button type="primary" :icon="Plus" @click="dialogVisible = true">添加节点</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:14px"
|
||||
title="节点用于将专线部署到中继服务器。本机节点为默认节点;远程节点需在目标机部署 agent(规划中)。" />
|
||||
<el-table :data="nodes" size="large">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="address" label="地址" />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.kind === 'local' ? 'success' : 'primary'" size="small">
|
||||
{{ row.kind === 'local' ? '本机' : '远程' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.kind !== 'local'" type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="添加节点" width="420px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="地址" required><el-input v-model="form.address" placeholder="agent 地址或 SSH 地址" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="create">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const nodes = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({ name: '', address: '', kind: 'remote', remark: '' })
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/nodes')
|
||||
nodes.value = data || []
|
||||
}
|
||||
async function create() {
|
||||
if (!form.value.name || !form.value.address) return ElMessage.warning('请填写名称与地址')
|
||||
await api.post('/nodes', form.value)
|
||||
dialogVisible.value = false
|
||||
form.value = { name: '', address: '', kind: 'remote', remark: '' }
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await api.delete(`/nodes/${row.id}`)
|
||||
load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>自定义协议插件</b>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">注册插件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:14px">
|
||||
<p style="margin:0">插件通过<b>命令钩子</b>实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。</p>
|
||||
<p style="margin:4px 0 0;font-size:12px">契约:<code>Create / Start / Stop / Status / Config</code>;Status 钩子 stdout 输出 <code>running</code> 或 <code>stopped</code>;Config 钩子 stdout 输出客户端配置文本。</p>
|
||||
</el-alert>
|
||||
<el-table :data="plugins" size="large">
|
||||
<el-table-column prop="display_name" label="名称" width="160" />
|
||||
<el-table-column label="协议标识" width="140">
|
||||
<template #default="{ row }"><code>custom:{{ row.name }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="edit(row)">编辑</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑插件' : '注册协议插件'" width="560px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="协议标识" required>
|
||||
<el-input v-model="form.name" :disabled="!!editing" placeholder="如 myproto(字母数字)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称"><el-input v-model="form.display_name" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="form.description" /></el-form-item>
|
||||
<el-form-item label="Start 钩子" required>
|
||||
<el-input v-model="form.start_hook" placeholder="/opt/plugins/myproto start" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Stop 钩子"><el-input v-model="form.stop_hook" placeholder="/opt/plugins/myproto stop" /></el-form-item>
|
||||
<el-form-item label="Status 钩子"><el-input v-model="form.status_hook" placeholder="/opt/plugins/myproto status" /></el-form-item>
|
||||
<el-form-item label="Config 钩子"><el-input v-model="form.config_hook" placeholder="/opt/plugins/myproto config" /></el-form-item>
|
||||
<el-form-item label="Create 钩子"><el-input v-model="form.create_hook" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const plugins = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref(null)
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return { name: '', display_name: '', description: '', create_hook: '', start_hook: '', stop_hook: '', status_hook: '', config_hook: '', enabled: true }
|
||||
}
|
||||
function openCreate() { editing.value = null; form.value = emptyForm(); dialogVisible.value = true }
|
||||
function edit(row) { editing.value = row; form.value = { ...row }; dialogVisible.value = true }
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/plugins')
|
||||
plugins.value = data || []
|
||||
}
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.start_hook) return ElMessage.warning('协议标识与 Start 钩子必填')
|
||||
try {
|
||||
if (editing.value) await api.put(`/plugins/${editing.value.id}`, form.value)
|
||||
else await api.post('/plugins', form.value)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('已保存')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
}
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`删除插件「${row.display_name}」?`, '确认', { type: 'warning' })
|
||||
await api.delete(`/plugins/${row.id}`)
|
||||
load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>修改密码</b></template>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="原密码"><el-input v-model="pwd.old_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="新密码"><el-input v-model="pwd.new_password" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="确认新密码"><el-input v-model="pwd.confirm" type="password" show-password /></el-form-item>
|
||||
<el-button type="primary" style="width:100%" @click="changePwd">修改</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>双因素认证(MFA)</b></template>
|
||||
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。" style="margin-bottom:10px" />
|
||||
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">设置 MFA</el-button>
|
||||
<el-button v-else type="danger" @click="disableMFA">关闭 MFA</el-button>
|
||||
<div v-if="mfa.uri" style="margin-top:12px"><canvas ref="mfaCanvas"></canvas><el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" /><el-button type="success" style="margin-top:8px" @click="enableMFA">验证并启用</el-button></div>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px">
|
||||
<template #header><b>部署说明</b></template>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="交付形态">单二进制(前端已内嵌)或 Docker</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">./data(可用 VPN_DATA_DIR 覆盖)</el-descriptions-item>
|
||||
<el-descriptions-item label="监听地址">:8080(VPN_LISTEN_ADDR 覆盖)</el-descriptions-item>
|
||||
<el-descriptions-item label="密钥存储">PSK / 私钥 AES-GCM 加密落库,配置文件 0600 权限</el-descriptions-item>
|
||||
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp(缺失时标记为未部署,无法启动)</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>操作审计(最近 100 条)</b></template>
|
||||
<el-table :data="logs" size="small" max-height="560">
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户" width="90" />
|
||||
<el-table-column prop="action" label="动作" width="140" />
|
||||
<el-table-column prop="target" label="对象" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
|
||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||
const logs = ref([])
|
||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||
const mfaCanvas = ref(null)
|
||||
|
||||
async function changePwd() {
|
||||
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning('两次新密码不一致')
|
||||
try {
|
||||
await api.post('/password', pwd.value)
|
||||
ElMessage.success('密码已修改')
|
||||
pwd.value = { old_password: '', new_password: '', confirm: '' }
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '修改失败')
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
const [audit, status] = await Promise.all([api.get('/audit'), api.get('/mfa/status')])
|
||||
logs.value = audit.data || []
|
||||
mfa.value.enabled = status.data.enabled
|
||||
})
|
||||
async function setupMFA() { const { data } = await api.post('/mfa/setup'); mfa.value.uri = data.otpauth_url; await nextTick(); QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 }) }
|
||||
async function enableMFA() { try { const { data } = await api.post('/mfa/enable', { code: mfa.value.code }); mfa.value = { enabled: true, uri: '', code: '' }; ElMessage.success(`MFA 已启用,请保存备用码:${data.backup_codes.join(' ')}`) } catch (e) { ElMessage.error(e.response?.data?.error || '验证失败') } }
|
||||
async function disableMFA() { await api.post('/mfa/disable'); mfa.value = { enabled: false, uri: '', code: '' }; ElMessage.success('MFA 已关闭') }
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never" v-loading="loading">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>编辑专线</b>
|
||||
<el-button @click="router.push('/tunnels')">返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:18px"
|
||||
title="密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。" />
|
||||
|
||||
<el-form v-if="form" label-width="170px" label-position="left" style="max-width:720px">
|
||||
<el-form-item label="专线名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="协议类型">
|
||||
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ protocolDisplay(protocol) }}</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="field in visibleFields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
|
||||
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
|
||||
<el-option v-for="option in field.options" :key="option" :label="option" :value="option" />
|
||||
</el-select>
|
||||
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
|
||||
show-password :placeholder="field.placeholder || '留空保持原值'" />
|
||||
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
|
||||
<div v-if="field.tip" class="field-tip">{{ field.tip }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="form.expire_at" type="datetime" clearable placeholder="留空为永久" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="自动启动"><el-switch v-model="form.auto_start" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||
<el-button @click="router.push('/tunnels')">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../api'
|
||||
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay } from '../protocols'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const protocol = ref('')
|
||||
const form = ref(null)
|
||||
|
||||
const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)
|
||||
const visibleFields = computed(() => fields.value.filter(field => {
|
||||
if (!field.showIf) return true
|
||||
return Object.entries(field.showIf).every(([key, value]) => Array.isArray(value)
|
||||
? value.includes(form.value.config[key])
|
||||
: form.value.config[key] === value)
|
||||
}))
|
||||
|
||||
function parseConfig(raw) {
|
||||
try { return JSON.parse(raw || '{}') } catch { return {} }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${route.params.id}`)
|
||||
const tunnel = data.tunnel
|
||||
protocol.value = tunnel.protocol
|
||||
const config = parseConfig(tunnel.config)
|
||||
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
|
||||
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
|
||||
}
|
||||
form.value = {
|
||||
name: tunnel.name,
|
||||
remark: tunnel.remark,
|
||||
auto_start: tunnel.auto_start,
|
||||
expire_at: tunnel.expire_at,
|
||||
config,
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '加载专线失败')
|
||||
router.push('/tunnels')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name) return ElMessage.warning('请填写专线名称')
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/tunnels/${route.params.id}`, form.value)
|
||||
ElMessage.success('配置已保存')
|
||||
router.push('/tunnels')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-tip { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.4; }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-steps :active="step" align-center style="margin-bottom:32px">
|
||||
<el-step title="选择协议" />
|
||||
<el-step title="填写参数" />
|
||||
<el-step title="生成配置" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:协议选择 -->
|
||||
<div v-if="step === 0" class="proto-grid">
|
||||
<div v-for="(meta, key) in builtin" :key="key" class="proto-card" :class="{ active: form.protocol === key }"
|
||||
@click="selectProtocol(key)">
|
||||
<div class="proto-icon">{{ meta.icon }}</div>
|
||||
<div class="proto-name" :style="{ color: meta.color }">{{ meta.name }}</div>
|
||||
<div class="proto-desc">{{ meta.desc }}</div>
|
||||
<div class="proto-scene">{{ meta.scenes }}</div>
|
||||
<div class="dep-badge">
|
||||
<el-tag v-if="depStatus[key]" :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
||||
{{ depStatus[key].available ? '依赖就绪' : '未部署' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
|
||||
@click="selectProtocol(p.name)">
|
||||
<div class="proto-icon">🧩</div>
|
||||
<div class="proto-name" style="color:#7b5ea7">{{ p.display_name }}</div>
|
||||
<div class="proto-desc">自定义协议插件</div>
|
||||
<div class="proto-scene">自定义对接</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:参数表单 -->
|
||||
<div v-if="step === 1" style="max-width:640px;margin:0 auto">
|
||||
<el-form label-width="150px" label-position="left">
|
||||
<el-form-item label="专线名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:总部-分支互联" />
|
||||
</el-form-item>
|
||||
<el-form-item label="协议类型">
|
||||
<el-tag :color="currentColor" effect="dark" style="border:none">{{ currentName }}</el-tag>
|
||||
<el-button link style="margin-left:10px" @click="step = 0">重新选择</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="f in visibleFields" :key="f.key" :label="f.label" :required="f.required">
|
||||
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
|
||||
<el-select v-else-if="f.type === 'select'" v-model="form.config[f.key]" style="width:100%">
|
||||
<el-option v-for="o in f.options" :key="o" :label="o" :value="o" />
|
||||
</el-select>
|
||||
<el-input v-else-if="f.type === 'password'" v-model="form.config[f.key]" type="password"
|
||||
show-password :placeholder="f.placeholder" />
|
||||
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
|
||||
<div class="field-tip" v-if="f.tip">{{ f.tip }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="form.expire_at" type="datetime" placeholder="留空为永久" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="创建后自动启动">
|
||||
<el-switch v-model="form.auto_start" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:center;margin-top:24px">
|
||||
<el-button @click="step = 0">上一步</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="create">生成配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:结果 -->
|
||||
<div v-if="step === 2" style="max-width:720px;margin:0 auto">
|
||||
<el-result icon="success" title="专线创建成功" :sub-title="`「${created?.name}」配置已生成`" />
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ clientConfig }}</pre>
|
||||
<div style="display:flex;gap:12px;justify-content:center;margin-top:16px">
|
||||
<el-button type="primary" @click="downloadCreated">下载配置文件</el-button>
|
||||
<el-button v-if="created?.protocol === 'wireguard'" @click="showQr">显示二维码</el-button>
|
||||
<el-button v-if="created?.protocol === 'ipsec'" @click="$router.push('/ipsec-peer')">前往 IPSec 对接页</el-button>
|
||||
<el-button @click="$router.push('/tunnels')">返回列表</el-button>
|
||||
</div>
|
||||
<canvas ref="qrCanvas" style="display:block;margin:16px auto" v-show="qrShown"></canvas>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor } from '../protocols'
|
||||
|
||||
const step = ref(0)
|
||||
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
|
||||
const creating = ref(false)
|
||||
const created = ref(null)
|
||||
const clientConfig = ref('')
|
||||
const depStatus = ref({})
|
||||
const customProtocols = ref([])
|
||||
const qrCanvas = ref(null)
|
||||
const qrShown = ref(false)
|
||||
|
||||
const builtin = PROTOCOL_META
|
||||
|
||||
const currentMeta = computed(() => PROTOCOL_META[form.value.protocol])
|
||||
const currentName = computed(() => currentMeta.value?.name || form.value.protocol.replace('custom:', ''))
|
||||
const currentColor = computed(() => protocolColor(form.value.protocol))
|
||||
const currentFields = computed(() => currentMeta.value?.fields || CUSTOM_FIELDS)
|
||||
const visibleFields = computed(() => currentFields.value.filter(f => {
|
||||
if (!f.showIf) return true
|
||||
return Object.entries(f.showIf).every(([k, v]) => Array.isArray(v)
|
||||
? v.includes(form.value.config[k])
|
||||
: form.value.config[k] === v)
|
||||
}))
|
||||
|
||||
function selectProtocol(key) {
|
||||
form.value.protocol = key
|
||||
form.value.config = {}
|
||||
// 填充默认值
|
||||
for (const f of (PROTOCOL_META[key]?.fields || CUSTOM_FIELDS)) {
|
||||
if (f.default !== undefined) form.value.config[f.key] = f.default
|
||||
}
|
||||
step.value = 1
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!form.value.name) return ElMessage.warning('请填写专线名称')
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await api.post('/tunnels', {
|
||||
name: form.value.name,
|
||||
protocol: form.value.protocol,
|
||||
remark: form.value.remark,
|
||||
auto_start: form.value.auto_start,
|
||||
expire_at: form.value.expire_at,
|
||||
config: form.value.config
|
||||
})
|
||||
created.value = data
|
||||
const detail = await api.get(`/tunnels/${data.id}`)
|
||||
clientConfig.value = detail.data.client_config || ''
|
||||
step.value = 2
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '创建失败')
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
|
||||
function downloadCreated() {
|
||||
fetch(`/api/tunnels/${created.value.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(b)
|
||||
a.download = `${created.value.protocol}-${created.value.name}.conf`
|
||||
a.click()
|
||||
})
|
||||
}
|
||||
|
||||
async function showQr() {
|
||||
qrShown.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, clientConfig.value, { width: 260, margin: 1 })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/protocols')
|
||||
for (const p of data) {
|
||||
if (p.custom) customProtocols.value.push({ name: p.name, display_name: p.display_name })
|
||||
else depStatus.value[p.name] = p
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; max-width: 1000px; margin: 0 auto; }
|
||||
.proto-card {
|
||||
border: 2px solid #e8eaee; border-radius: 12px; padding: 22px 16px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff;
|
||||
}
|
||||
.proto-card:hover { border-color: #4a6fa5; transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,.08); }
|
||||
.proto-card.active { border-color: #4a6fa5; background: #f0f5fc; }
|
||||
.proto-icon { font-size: 34px; }
|
||||
.proto-name { font-size: 16px; font-weight: 700; margin: 8px 0 4px; }
|
||||
.proto-desc { font-size: 12px; color: #606266; min-height: 34px; }
|
||||
.proto-scene { font-size: 11px; color: #909399; margin-top: 6px; }
|
||||
.dep-badge { margin-top: 8px; }
|
||||
.field-tip { font-size: 12px; color: #909399; line-height: 1.4; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 14px; max-height: 320px; overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<el-input v-model="keyword" placeholder="搜索名称" clearable style="width:200px" @change="load" />
|
||||
<el-select v-model="protocolFilter" placeholder="全部协议" clearable style="width:160px;margin-left:10px" @change="load">
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建专线</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" size="large">
|
||||
<el-table-column prop="name" label="名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<b>{{ row.name }}</b>
|
||||
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="protocolColor(row.protocol)" effect="dark" style="border:none">
|
||||
{{ protocolDisplay(row.protocol) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-badge is-dot :type="dotType(row)" style="padding-left:12px">
|
||||
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
|
||||
</el-badge>
|
||||
<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-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流量 ↓ / ↑" width="140">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="150">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : '永久' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节点" width="110">
|
||||
<template #default="{ row }">{{ row.node?.name || '本机节点' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">启动</el-button>
|
||||
<el-button v-else type="warning" size="small" link @click="stop(row)">停止</el-button>
|
||||
<el-button size="small" link @click="showDetail(row)">详情</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">编辑</el-button>
|
||||
<el-button size="small" link @click="download(row)">下载配置</el-button>
|
||||
<el-button v-if="row.protocol === 'wireguard'" size="small" link @click="showQr(row)">二维码</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有专线,点击右上角创建第一条" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="520px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="协议">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ stateText(detail.tunnel) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自动启动">{{ detail.tunnel.auto_start ? '是' : '否' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4>运行参数</h4>
|
||||
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ detail.client_config || '该协议暂无客户端配置' }}</pre>
|
||||
<el-button type="primary" style="width:100%" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- WireGuard 二维码 -->
|
||||
<el-dialog v-model="qrVisible" title="扫码导入 WireGuard 客户端" width="360px" align-center>
|
||||
<div style="text-align:center">
|
||||
<canvas ref="qrCanvas"></canvas>
|
||||
<p style="color:#909399;font-size:12px">使用 WireGuard App「从二维码添加」</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, InfoFilled } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { protocolDisplay, protocolColor } from '../protocols'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const protocolFilter = ref('')
|
||||
const protocolOptions = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const qrVisible = ref(false)
|
||||
const qrCanvas = ref(null)
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + u[i]
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
function expiringSoon(row) {
|
||||
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
|
||||
}
|
||||
function stateText(row) {
|
||||
return { running: '运行中', stopped: '已停止', error: '异常', undeployed: '未部署' }[stateOf(row)] || stateOf(row)
|
||||
}
|
||||
function stateColor(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 prettyConfig(c) {
|
||||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||||
list.value = data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function start(row) {
|
||||
try {
|
||||
await api.post(`/tunnels/${row.id}/start`)
|
||||
ElMessage.success('已启动')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '启动失败')
|
||||
}
|
||||
load()
|
||||
}
|
||||
async function stop(row) {
|
||||
await api.post(`/tunnels/${row.id}/stop`)
|
||||
ElMessage.success('已停止')
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`确定删除专线「${row.name}」?配置与密钥将一并清除。`, '删除确认', { type: 'warning' })
|
||||
await api.delete(`/tunnels/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
async function showDetail(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
}
|
||||
function download(row) {
|
||||
// 带鉴权下载
|
||||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
const url = URL.createObjectURL(b)
|
||||
a.href = url
|
||||
a.download = `${row.protocol}-${row.name}.conf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
async function showQr(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
if (!data.client_config) return ElMessage.warning('无客户端配置')
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(async () => {
|
||||
load()
|
||||
timer = setInterval(load, 15000)
|
||||
const { data } = await api.get('/protocols')
|
||||
protocolOptions.value = data.map(p => p.name)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remark { font-size: 12px; color: #909399; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 12px; max-height: 260px; overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="15">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><div style="display:flex;justify-content:space-between"><b>管理用户</b><el-button type="primary" @click="openCreate">添加本地用户</el-button></div></template>
|
||||
<el-table :data="users" size="small">
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="来源" width="90"><template #default="{row}"><el-tag size="small">{{ row.source }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="角色" width="100"><template #default="{row}"><el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'operator' ? 'warning' : 'info'" size="small">{{ row.role }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="130"><template #default="{row}"><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button><el-button v-if="row.username !== auth.username" type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>LDAP 身份源</b></template>
|
||||
<el-alert type="info" :closable="false" title="LDAP 用户首次登录后同步为只读管理用户;可由管理员调整角色。" style="margin-bottom:12px" />
|
||||
<el-form label-width="88px" size="small">
|
||||
<el-form-item label="启用"><el-switch v-model="ldap.enabled" /></el-form-item>
|
||||
<el-form-item label="LDAP URL"><el-input v-model="ldap.url" placeholder="ldaps://ldap.example.com:636" /></el-form-item>
|
||||
<el-form-item label="Bind DN"><el-input v-model="ldap.bind_dn" /></el-form-item>
|
||||
<el-form-item label="Bind 密码"><el-input v-model="ldap.bind_password" type="password" show-password placeholder="留空保持原值" /></el-form-item>
|
||||
<el-form-item label="Base DN"><el-input v-model="ldap.base_dn" placeholder="dc=example,dc=com" /></el-form-item>
|
||||
<el-form-item label="用户过滤"><el-input v-model="ldap.user_filter" placeholder="(objectClass=inetOrgPerson)" /></el-form-item>
|
||||
<el-form-item label="用户名属性"><el-input v-model="ldap.username_attribute" placeholder="uid" /></el-form-item>
|
||||
<el-button type="primary" @click="saveLDAP">保存</el-button><el-button @click="testLDAP">测试连接</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-dialog v-model="visible" title="添加本地管理用户" width="420px">
|
||||
<el-form label-width="80px"><el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" show-password /></el-form-item><el-form-item label="角色"><el-select v-model="form.role"><el-option value="admin" label="管理员" /><el-option value="operator" label="运维员" /><el-option value="viewer" label="只读" /></el-select></el-form-item></el-form>
|
||||
<template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api, { auth } from '../api'
|
||||
const users = ref([]), visible = ref(false)
|
||||
const form = ref({ username: '', email: '', password: '', role: 'viewer', enabled: true })
|
||||
const ldap = ref({ enabled: false, url: '', bind_dn: '', bind_password: '', base_dn: '', user_filter: '(objectClass=inetOrgPerson)', username_attribute: 'uid' })
|
||||
async function load () { const [u, l] = await Promise.all([api.get('/users'), api.get('/ldap')]); users.value = u.data || []; ldap.value = { ...ldap.value, ...l.data, bind_password: '' } }
|
||||
function openCreate () { form.value = { username: '', email: '', password: '', role: 'viewer', enabled: true }; visible.value = true }
|
||||
async function create () { try { await api.post('/users', form.value); visible.value = false; ElMessage.success('用户已创建'); load() } catch (e) { ElMessage.error(e.response?.data?.error || '创建失败') } }
|
||||
async function toggle (row) { await api.put(`/users/${row.id}`, { enabled: !row.enabled }); load() }
|
||||
async function remove (row) { await ElMessageBox.confirm(`删除用户 ${row.username}?`, '确认'); await api.delete(`/users/${row.id}`); load() }
|
||||
async function saveLDAP () { try { await api.put('/ldap', ldap.value); ldap.value.bind_password = ''; ElMessage.success('LDAP 配置已保存') } catch (e) { ElMessage.error(e.response?.data?.error || '保存失败') } }
|
||||
async function testLDAP () { try { await api.post('/ldap/test'); ElMessage.success('LDAP 连接成功') } catch (e) { ElMessage.error(e.response?.data?.error || '连接失败') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><div><b>VPN 拨号用户</b><span style="margin-left:8px;color:#909399;font-size:12px">本地密码或 LDAP 身份,按 OpenVPN 用户统计流量</span></div><el-button type="primary" @click="openCreate">添加用户</el-button></div></template>
|
||||
<el-table :data="users" size="small">
|
||||
<el-table-column prop="username" label="用户名" /><el-table-column prop="display_name" label="姓名" /><el-table-column label="隧道"><template #default="{row}">{{ tunnelName(row.tunnel_id) }}</template></el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="90" /><el-table-column label="流量 ↓ / ↑" width="170"><template #default="{row}">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template></el-table-column>
|
||||
<el-table-column label="最近在线" width="180"><template #default="{row}">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{row}"><el-tag :type="row.enabled ? 'success':'info'" size="small">{{ row.enabled ? '启用':'禁用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="180"><template #default="{row}"><el-button link size="small" @click="download(row)">下载配置</el-button><el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用':'启用' }}</el-button><el-button type="danger" link size="small" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px"><el-form label-width="100px"><el-form-item label="OpenVPN 隧道" required><el-select v-model="form.tunnel_id" style="width:100%"><el-option v-for="t in tunnels" :key="t.id" :value="t.id" :label="t.name" /></el-select></el-form-item><el-form-item label="用户名" required><el-input v-model="form.username" /></el-form-item><el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item><el-form-item label="认证来源"><el-radio-group v-model="form.source"><el-radio-button value="local">本地</el-radio-button><el-radio-button value="ldap">LDAP</el-radio-button></el-radio-group></el-form-item><el-form-item v-if="form.source==='local'" label="密码" required><el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" /></el-form-item><el-form-item v-else label="LDAP DN" required><el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></el-form><template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template></el-dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '../api'
|
||||
const users=ref([]), tunnels=ref([]), visible=ref(false)
|
||||
const form=ref({tunnel_id:null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true})
|
||||
const bytes=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(n>=1024&&i<4){n/=1024;i++}return `${n.toFixed(1)} ${u[i]}`}
|
||||
const tunnelName=id=>tunnels.value.find(t=>t.id===id)?.name||`#${id}`
|
||||
async function load(){const [u,t]=await Promise.all([api.get('/vpn-users'),api.get('/tunnels',{params:{protocol:'openvpn'}})]);users.value=u.data||[];tunnels.value=t.data||[]}
|
||||
function openCreate(){form.value={tunnel_id:tunnels.value[0]?.id||null,username:'',display_name:'',source:'local',password:'',ldap_dn:'',email:'',enabled:true};visible.value=true}
|
||||
async function create(){try{await api.post('/vpn-users',form.value);visible.value=false;ElMessage.success('拨号用户已创建');load()}catch(e){ElMessage.error(e.response?.data?.error||'创建失败')}}
|
||||
async function toggle(row){await api.put(`/vpn-users/${row.id}`,{enabled:!row.enabled,display_name:row.display_name,email:row.email});load()}
|
||||
async function remove(row){await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`,'确认');await api.delete(`/vpn-users/${row.id}`);load()}
|
||||
async function download(row){try{const res=await fetch(`/api/vpn-users/${row.id}/config`,{headers:{Authorization:`Bearer ${localStorage.getItem('vpn_token')}`}});if(!res.ok){const data=await res.json();throw new Error(data.error)}const url=URL.createObjectURL(await res.blob());const a=document.createElement('a');a.href=url;a.download=`openvpn-${row.username}.ovpn`;a.click();URL.revokeObjectURL(url)}catch(e){ElMessage.error(e.message||'下载失败')}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': 'http://localhost:8080' }
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/dist',
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 2000
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
module vpn-platform
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
golang.org/x/crypto v0.23.0
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.8 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pquerna/otp v1.4.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/bin/bash
|
||||
# VPN 专线管理平台 启动脚本 // Logan.Li
|
||||
# 单二进制内嵌前端交付:构建前端 → 编译 Go → 启动单二进制
|
||||
# 用法: ./run.sh [build-frontend|build|run|dev|stop|restart|status|help]
|
||||
#
|
||||
# 与 opsx-web-lite 的区别:
|
||||
# - opsx-web-lite 是前后端分离 dev 模式(backend + frontend 独立进程)
|
||||
# - 本项目把前端构建产物 embed 进 Go 二进制,最终 ./opsx-vpn 单文件运行
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================================
|
||||
# 配置区
|
||||
# ============================================================
|
||||
PROJECT_NAME="VPN 管理平台"
|
||||
BACKEND_PORT=8080
|
||||
FRONTEND_PORT=5173
|
||||
BINARY_NAME="opsx-vpn"
|
||||
FRONTEND_DIR="frontend"
|
||||
BACKEND_DIR="backend"
|
||||
|
||||
# 日志配置
|
||||
MAX_LOG_FILES=3
|
||||
MAX_LOG_SIZE=$((50 * 1024 * 1024)) # 50MB
|
||||
|
||||
# ============================================================
|
||||
# 通用函数
|
||||
# ============================================================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# PID 文件
|
||||
PID_FILE="$SCRIPT_DIR/.vpn.pid"
|
||||
|
||||
# 检查端口是否被占用
|
||||
check_port() {
|
||||
lsof -ti:$1 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# 释放端口
|
||||
kill_port() {
|
||||
local pids=$(lsof -ti:$1 2>/dev/null || echo "")
|
||||
if [ -n "$pids" ]; then
|
||||
info "释放端口 $1..."
|
||||
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 日志轮转(仅启动时调用)
|
||||
rotate_log() {
|
||||
local log_file=$1
|
||||
[ ! -f "$log_file" ] && return 0
|
||||
|
||||
local file_size=$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file" 2>/dev/null || echo "0")
|
||||
if [ "$file_size" -gt "$MAX_LOG_SIZE" ]; then
|
||||
[ -f "${log_file}.${MAX_LOG_FILES}" ] && rm -f "${log_file}.${MAX_LOG_FILES}"
|
||||
for i in $(seq $((MAX_LOG_FILES - 1)) -1 1); do
|
||||
[ -f "${log_file}.${i}" ] && mv "${log_file}.${i}" "${log_file}.$((i + 1))"
|
||||
done
|
||||
mv "$log_file" "${log_file}.1"
|
||||
info "日志已轮转: $log_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 构建前端
|
||||
# ============================================================
|
||||
build_frontend() {
|
||||
info "========== 构建前端 =========="
|
||||
cd "$SCRIPT_DIR/$FRONTEND_DIR"
|
||||
|
||||
if [ ! -d "node_modules" ]; then
|
||||
warning "node_modules 不存在,正在安装依赖..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
info "执行 vite build(产物输出到 ../$BACKEND_DIR/dist)..."
|
||||
npm run build
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
success "前端构建完成"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 编译单二进制
|
||||
# ============================================================
|
||||
build_binary() {
|
||||
info "========== 编译 Go 单二进制 =========="
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# dist 必须存在(embed 需要至少一个文件)
|
||||
if [ ! -d "$BACKEND_DIR/dist" ]; then
|
||||
mkdir -p "$BACKEND_DIR/dist"
|
||||
echo "<!doctype html><html><body>请先构建前端</body></html>" > "$BACKEND_DIR/dist/index.html"
|
||||
fi
|
||||
|
||||
info "go build -ldflags='-s -w' -o $BINARY_NAME ./$BACKEND_DIR"
|
||||
go build -ldflags="-s -w" -o "$BINARY_NAME" "./$BACKEND_DIR"
|
||||
success "二进制编译完成: $BINARY_NAME"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 全量构建(前端 + 后端)
|
||||
# ============================================================
|
||||
build_all() {
|
||||
build_frontend
|
||||
build_binary
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 运行单二进制
|
||||
# ============================================================
|
||||
start_binary() {
|
||||
info "========== 启动 $PROJECT_NAME =========="
|
||||
cd "$SCRIPT_DIR"
|
||||
mkdir -p logs data
|
||||
rotate_log "logs/vpn.log"
|
||||
|
||||
# 每次启动前都将最新前端与后端合并为单一二进制
|
||||
build_all
|
||||
|
||||
kill_port $BACKEND_PORT
|
||||
|
||||
export VPN_LISTEN_ADDR=":${BACKEND_PORT}"
|
||||
|
||||
nohup ./"$BINARY_NAME" >> logs/vpn.log 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
|
||||
# 等待服务启动
|
||||
local retries=0
|
||||
while ! curl -s "http://localhost:$BACKEND_PORT/api/health" > /dev/null 2>&1; do
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge 30 ]; then
|
||||
error "服务启动超时,请检查日志: logs/vpn.log"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
success "服务已启动 (PID: $(cat "$PID_FILE"), 端口: $BACKEND_PORT)"
|
||||
info "访问地址: http://localhost:$BACKEND_PORT"
|
||||
info "默认账号: admin / admin123"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 开发模式(前后端分离)
|
||||
# ============================================================
|
||||
start_dev() {
|
||||
info "========== $PROJECT_NAME 开发模式 =========="
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if ! command -v go &> /dev/null; then
|
||||
error "Go 未安装,请先安装 Go"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v npm &> /dev/null; then
|
||||
error "npm 未安装,请先安装 Node.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p logs data
|
||||
|
||||
# 后端:go run(前台 / 后台均可,这里后台以便同时跑前端)
|
||||
kill_port $BACKEND_PORT
|
||||
export VPN_LISTEN_ADDR=":${BACKEND_PORT}"
|
||||
export GIN_MODE=debug
|
||||
nohup go run "./$BACKEND_DIR" >> logs/vpn-dev.log 2>&1 &
|
||||
echo $! > "$PID_FILE.dev"
|
||||
info "后端已启动 (go run, 端口: $BACKEND_PORT)"
|
||||
|
||||
# 前端:vite dev server
|
||||
cd "$FRONTEND_DIR"
|
||||
if [ ! -d "node_modules" ]; then
|
||||
warning "node_modules 不存在,正在安装依赖..."
|
||||
npm install
|
||||
fi
|
||||
kill_port $FRONTEND_PORT
|
||||
nohup npm run dev >> ../logs/frontend-dev.log 2>&1 &
|
||||
echo $! > "../.frontend.pid"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 等待前端就绪
|
||||
local retries=0
|
||||
while ! curl -s "http://localhost:$FRONTEND_PORT" > /dev/null 2>&1; do
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -ge 40 ]; then
|
||||
error "前端启动超时,请检查日志: logs/frontend-dev.log"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
success "开发模式已启动"
|
||||
info "前端地址: http://localhost:$FRONTEND_PORT(代理 /api 到 $BACKEND_PORT)"
|
||||
info "后端地址: http://localhost:$BACKEND_PORT"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 停止服务
|
||||
# ============================================================
|
||||
stop_all() {
|
||||
info "正在停止服务..."
|
||||
|
||||
# 单二进制
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
success "单二进制服务已停止"
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
kill_port $BACKEND_PORT
|
||||
|
||||
# 开发模式后端
|
||||
if [ -f "$PID_FILE.dev" ]; then
|
||||
local devpid=$(cat "$PID_FILE.dev" 2>/dev/null || echo "")
|
||||
if [ -n "$devpid" ] && kill -0 "$devpid" 2>/dev/null; then
|
||||
kill "$devpid" 2>/dev/null || true
|
||||
success "后端 dev 进程已停止"
|
||||
fi
|
||||
rm -f "$PID_FILE.dev"
|
||||
fi
|
||||
|
||||
# 开发模式前端
|
||||
if [ -f "$SCRIPT_DIR/.frontend.pid" ]; then
|
||||
local fpid=$(cat "$SCRIPT_DIR/.frontend.pid" 2>/dev/null || echo "")
|
||||
if [ -n "$fpid" ] && kill -0 "$fpid" 2>/dev/null; then
|
||||
kill "$fpid" 2>/dev/null || true
|
||||
success "前端 dev 进程已停止"
|
||||
fi
|
||||
rm -f "$SCRIPT_DIR/.frontend.pid"
|
||||
fi
|
||||
kill_port $FRONTEND_PORT
|
||||
|
||||
info "所有服务已停止"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 状态查看
|
||||
# ============================================================
|
||||
show_status() {
|
||||
info "服务状态:"
|
||||
if [ -n "$(check_port $BACKEND_PORT)" ]; then
|
||||
local pid=$(check_port $BACKEND_PORT)
|
||||
success "后端服务: 运行中 (PID: $pid, 端口: $BACKEND_PORT)"
|
||||
else
|
||||
warning "后端服务: 未运行"
|
||||
fi
|
||||
|
||||
if [ -n "$(check_port $FRONTEND_PORT)" ]; then
|
||||
local fpid=$(lsof -ti:$FRONTEND_PORT 2>/dev/null | grep -v Chrome | head -1)
|
||||
if [ -n "$fpid" ]; then
|
||||
success "前端 dev : 运行中 (PID: $fpid, 端口: $FRONTEND_PORT)"
|
||||
fi
|
||||
else
|
||||
info "前端 dev : 未运行(生产模式由后端内嵌提供)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 帮助
|
||||
# ============================================================
|
||||
show_help() {
|
||||
echo "$PROJECT_NAME 启动脚本"
|
||||
echo ""
|
||||
echo "用法: ./run.sh [命令]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " run 构建并后台运行单二进制(默认)"
|
||||
echo " build 全量构建(前端 + 后端二进制)"
|
||||
echo " build-frontend 仅构建前端(输出到 backend/dist)"
|
||||
echo " build-binary 仅编译 Go 二进制"
|
||||
echo " dev 开发模式(前后端分离,vite 代理到后端)"
|
||||
echo " stop 停止所有服务"
|
||||
echo " restart 重启服务"
|
||||
echo " status 查看服务状态"
|
||||
echo " help 显示帮助信息"
|
||||
echo ""
|
||||
echo "环境变量:"
|
||||
echo " VPN_LISTEN_ADDR 监听地址(默认 :8080)"
|
||||
echo " VPN_DATA_DIR 数据目录(默认 ./data)"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./run.sh # 构建并运行"
|
||||
echo " ./run.sh build # 仅构建"
|
||||
echo " ./run.sh dev # 开发模式"
|
||||
echo " ./run.sh stop # 停止服务"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 主入口
|
||||
# ============================================================
|
||||
main() {
|
||||
local command=${1:-run}
|
||||
|
||||
case "$command" in
|
||||
run)
|
||||
start_binary
|
||||
;;
|
||||
build)
|
||||
build_all
|
||||
;;
|
||||
build-frontend)
|
||||
build_frontend
|
||||
;;
|
||||
build-binary)
|
||||
build_binary
|
||||
;;
|
||||
dev)
|
||||
start_dev
|
||||
;;
|
||||
stop)
|
||||
stop_all
|
||||
;;
|
||||
restart)
|
||||
stop_all
|
||||
sleep 2
|
||||
start_binary
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
error "未知命令: $command"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in new issue
Block a user