From 58c036c34c94c49272721ed36d7a6c64bf606147 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 22 Jul 2026 21:49:13 +0800 Subject: [PATCH] feat: implement VPN platform management and real protocol services --- .gitignore | 45 + Makefile | 64 + README.md | 110 + backend/internal/api/api.go | 781 ++++++ backend/internal/api/identity.go | 415 ++++ backend/internal/core/protocol.go | 123 + backend/internal/model/model.go | 117 + backend/internal/monitor/monitor.go | 74 + backend/internal/service/common.go | 305 +++ backend/internal/service/custom/custom.go | 138 ++ backend/internal/service/frp/frp.go | 388 +++ backend/internal/service/frp/frp_test.go | 37 + backend/internal/service/ipsec/ipsec.go | 421 ++++ backend/internal/service/l2tp/l2tp.go | 411 ++++ backend/internal/service/openvpn/openvpn.go | 514 ++++ .../internal/service/openvpn/openvpn_test.go | 26 + .../internal/service/wireguard/wireguard.go | 676 ++++++ .../service/wireguard/wireguard_test.go | 127 + backend/internal/store/store.go | 165 ++ backend/main.go | 108 + frontend/index.html | 12 + frontend/package-lock.json | 2085 +++++++++++++++++ frontend/package.json | 23 + frontend/src/App.vue | 3 + frontend/src/api.js | 40 + frontend/src/main.js | 12 + frontend/src/protocols.js | 117 + frontend/src/router.js | 32 + frontend/src/style.css | 13 + frontend/src/views/Dashboard.vue | 111 + frontend/src/views/IpsecPeer.vue | 185 ++ frontend/src/views/Layout.vue | 68 + frontend/src/views/Login.vue | 71 + frontend/src/views/Nodes.vue | 69 + frontend/src/views/Plugins.vue | 97 + frontend/src/views/Settings.vue | 76 + frontend/src/views/TunnelEditor.vue | 112 + frontend/src/views/TunnelWizard.vue | 185 ++ frontend/src/views/Tunnels.vue | 204 ++ frontend/src/views/Users.vue | 53 + frontend/src/views/VPNUsers.vue | 29 + frontend/vite.config.js | 15 + go.mod | 53 + go.sum | 186 ++ run.sh | 348 +++ 45 files changed, 9244 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/internal/api/api.go create mode 100644 backend/internal/api/identity.go create mode 100644 backend/internal/core/protocol.go create mode 100644 backend/internal/model/model.go create mode 100644 backend/internal/monitor/monitor.go create mode 100644 backend/internal/service/common.go create mode 100644 backend/internal/service/custom/custom.go create mode 100644 backend/internal/service/frp/frp.go create mode 100644 backend/internal/service/frp/frp_test.go create mode 100644 backend/internal/service/ipsec/ipsec.go create mode 100644 backend/internal/service/l2tp/l2tp.go create mode 100644 backend/internal/service/openvpn/openvpn.go create mode 100644 backend/internal/service/openvpn/openvpn_test.go create mode 100644 backend/internal/service/wireguard/wireguard.go create mode 100644 backend/internal/service/wireguard/wireguard_test.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/main.go create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/protocols.js create mode 100644 frontend/src/router.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/IpsecPeer.vue create mode 100644 frontend/src/views/Layout.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/Nodes.vue create mode 100644 frontend/src/views/Plugins.vue create mode 100644 frontend/src/views/Settings.vue create mode 100644 frontend/src/views/TunnelEditor.vue create mode 100644 frontend/src/views/TunnelWizard.vue create mode 100644 frontend/src/views/Tunnels.vue create mode 100644 frontend/src/views/Users.vue create mode 100644 frontend/src/views/VPNUsers.vue create mode 100644 frontend/vite.config.js create mode 100644 go.mod create mode 100644 go.sum create mode 100755 run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2dd38e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..86f5661 --- /dev/null +++ b/Makefile @@ -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 '请先构建前端' > 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 "🧹 清理完成" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6a168b --- /dev/null +++ b/README.md @@ -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、不依赖外部数据库 diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go new file mode 100644 index 0000000..a28c2b6 --- /dev/null +++ b/backend/internal/api/api.go @@ -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 +} diff --git a/backend/internal/api/identity.go b/backend/internal/api/identity.go new file mode 100644 index 0000000..ed8947c --- /dev/null +++ b/backend/internal/api/identity.go @@ -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}) +} diff --git a/backend/internal/core/protocol.go b/backend/internal/core/protocol.go new file mode 100644 index 0000000..573beb9 --- /dev/null +++ b/backend/internal/core/protocol.go @@ -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) +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go new file mode 100644 index 0000000..1f1677b --- /dev/null +++ b/backend/internal/model/model.go @@ -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"` +} diff --git a/backend/internal/monitor/monitor.go b/backend/internal/monitor/monitor.go new file mode 100644 index 0000000..eb77737 --- /dev/null +++ b/backend/internal/monitor/monitor.go @@ -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") + } + } + } +} diff --git a/backend/internal/service/common.go b/backend/internal/service/common.go new file mode 100644 index 0000000..7e79572 --- /dev/null +++ b/backend/internal/service/common.go @@ -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 +} diff --git a/backend/internal/service/custom/custom.go b/backend/internal/service/custom/custom.go new file mode 100644 index 0000000..aac5508 --- /dev/null +++ b/backend/internal/service/custom/custom.go @@ -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])) + } +} diff --git a/backend/internal/service/frp/frp.go b/backend/internal/service/frp/frp.go new file mode 100644 index 0000000..0f989a5 --- /dev/null +++ b/backend/internal/service/frp/frp.go @@ -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 +} diff --git a/backend/internal/service/frp/frp_test.go b/backend/internal/service/frp/frp_test.go new file mode 100644 index 0000000..5e1288f --- /dev/null +++ b/backend/internal/service/frp/frp_test.go @@ -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") + } +} diff --git a/backend/internal/service/ipsec/ipsec.go b/backend/internal/service/ipsec/ipsec.go new file mode 100644 index 0000000..e3caccc --- /dev/null +++ b/backend/internal/service/ipsec/ipsec.go @@ -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 +} diff --git a/backend/internal/service/l2tp/l2tp.go b/backend/internal/service/l2tp/l2tp.go new file mode 100644 index 0000000..b71bf5b --- /dev/null +++ b/backend/internal/service/l2tp/l2tp.go @@ -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 +} diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go new file mode 100644 index 0000000..34c2421 --- /dev/null +++ b/backend/internal/service/openvpn/openvpn.go @@ -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("\n" + ca + "\n") + if cert != "" { + b.WriteString("\n" + cert + "\n\n" + key + "\n") + } + b.WriteString("\n" + ta + "\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 +} diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go new file mode 100644 index 0000000..0d75bec --- /dev/null +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -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) + } +} diff --git a/backend/internal/service/wireguard/wireguard.go b/backend/internal/service/wireguard/wireguard.go new file mode 100644 index 0000000..4f32058 --- /dev/null +++ b/backend/internal/service/wireguard/wireguard.go @@ -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 +} diff --git a/backend/internal/service/wireguard/wireguard_test.go b/backend/internal/service/wireguard/wireguard_test.go new file mode 100644 index 0000000..21f62fb --- /dev/null +++ b/backend/internal/service/wireguard/wireguard_test.go @@ -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" +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..b15d94b --- /dev/null +++ b/backend/internal/store/store.go @@ -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 +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..cce8758 --- /dev/null +++ b/backend/main.go @@ -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) + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b40bf5a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + VPN 专线管理平台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..883f11a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2085 @@ +{ + "name": "vpn-platform-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vpn-platform-web", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.7", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.7.tgz", + "integrity": "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b0edcb9 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..29ac109 --- /dev/null +++ b/frontend/src/api.js @@ -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 diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..257f0f4 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/protocols.js b/frontend/src/protocols.js new file mode 100644 index 0000000..75ab3b9 --- /dev/null +++ b/frontend/src/protocols.js @@ -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' +} diff --git a/frontend/src/router.js b/frontend/src/router.js new file mode 100644 index 0000000..9c344a1 --- /dev/null +++ b/frontend/src/router.js @@ -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 diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..66d0d53 --- /dev/null +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..ca865bd --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/views/IpsecPeer.vue b/frontend/src/views/IpsecPeer.vue new file mode 100644 index 0000000..b4c66bf --- /dev/null +++ b/frontend/src/views/IpsecPeer.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..049ba3a --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..5983f53 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/src/views/Nodes.vue b/frontend/src/views/Nodes.vue new file mode 100644 index 0000000..c086625 --- /dev/null +++ b/frontend/src/views/Nodes.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/views/Plugins.vue b/frontend/src/views/Plugins.vue new file mode 100644 index 0000000..3ec2a28 --- /dev/null +++ b/frontend/src/views/Plugins.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..128e1ae --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/views/TunnelEditor.vue b/frontend/src/views/TunnelEditor.vue new file mode 100644 index 0000000..e86d52c --- /dev/null +++ b/frontend/src/views/TunnelEditor.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/views/TunnelWizard.vue b/frontend/src/views/TunnelWizard.vue new file mode 100644 index 0000000..8c02f5c --- /dev/null +++ b/frontend/src/views/TunnelWizard.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/views/Tunnels.vue b/frontend/src/views/Tunnels.vue new file mode 100644 index 0000000..67c702c --- /dev/null +++ b/frontend/src/views/Tunnels.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue new file mode 100644 index 0000000..6764827 --- /dev/null +++ b/frontend/src/views/Users.vue @@ -0,0 +1,53 @@ + + diff --git a/frontend/src/views/VPNUsers.vue b/frontend/src/views/VPNUsers.vue new file mode 100644 index 0000000..b043317 --- /dev/null +++ b/frontend/src/views/VPNUsers.vue @@ -0,0 +1,29 @@ + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1f9a7f3 --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b033e1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..39caa38 --- /dev/null +++ b/go.sum @@ -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= diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..aeb68a3 --- /dev/null +++ b/run.sh @@ -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 "请先构建前端" > "$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 "$@"