// Package api 提供 REST API 路由与处理器 package api import ( "bufio" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "log" "net" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "vpn-platform/backend/internal/binmgr" "vpn-platform/backend/internal/core" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service" "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/sessionmgr" "vpn-platform/backend/internal/sshutil" "vpn-platform/backend/internal/store" "vpn-platform/backend/internal/taskmgr" "vpn-platform/backend/internal/trafficmgr" ) // Server API 服务器 type Server struct { mgr *core.Manager taskMgr *taskmgr.Manager } func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} } // ---- Token 会话(内存态,定期清理过期项)---- var ( sessions = map[string]sessionInfo{} sessionsMu sync.RWMutex ) type sessionInfo struct { username string userID uint role string mustChange bool expireAt time.Time } func init() { go func() { ticker := time.NewTicker(10 * time.Minute) for range ticker.C { now := time.Now() sessionsMu.Lock() for token, sess := range sessions { if now.After(sess.expireAt) { delete(sessions, token) } } sessionsMu.Unlock() loginMu.Lock() for key, rec := range loginFails { if now.After(rec.lockedUntil) && now.Sub(rec.lastFail) > 30*time.Minute { delete(loginFails, key) } } loginMu.Unlock() } }() } func issueToken(user *model.User) string { 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, mustChange: user.MustChangePassword, expireAt: time.Now().Add(24 * time.Hour)} sessionsMu.Unlock() return token } func revokeToken(token string) { sessionsMu.Lock() delete(sessions, token) sessionsMu.Unlock() } func clearMustChange(userID uint) { sessionsMu.Lock() for token, sess := range sessions { if sess.userID == userID { sess.mustChange = false sessions[token] = sess } } sessionsMu.Unlock() } // ---- 登录防爆破:同 IP+账号 连续失败 5 次锁定 10 分钟 ---- var ( loginFails = map[string]*loginFailRecord{} loginMu sync.Mutex ) type loginFailRecord struct { fails int lastFail time.Time lockedUntil time.Time } func loginLockRemaining(key string) time.Duration { loginMu.Lock() defer loginMu.Unlock() if rec, ok := loginFails[key]; ok && time.Now().Before(rec.lockedUntil) { return time.Until(rec.lockedUntil) } return 0 } func recordLoginFail(key string) { loginMu.Lock() defer loginMu.Unlock() rec, ok := loginFails[key] if !ok { rec = &loginFailRecord{} loginFails[key] = rec } rec.fails++ rec.lastFail = time.Now() if rec.fails >= 5 { rec.lockedUntil = time.Now().Add(10 * time.Minute) rec.fails = 0 } } func resetLoginFail(key string) { loginMu.Lock() delete(loginFails, key) loginMu.Unlock() } // ---- 专线操作互斥锁 ---- var tunnelLocks sync.Map func lockTunnel(id uint) *sync.Mutex { v, _ := tunnelLocks.LoadOrStore(id, &sync.Mutex{}) mu := v.(*sync.Mutex) mu.Lock() return mu } func securityHeaders() gin.HandlerFunc { return func(c *gin.Context) { h := c.Writer.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'") c.Next() } } func authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("Authorization") token = strings.TrimPrefix(token, "Bearer ") // SSE (EventSource) 不支持自定义 header,降级从 query 参数读 token if token == "" { token = c.Query("token") } sessionsMu.RLock() sess, ok := sessions[token] sessionsMu.RUnlock() 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.Set("must_change", sess.mustChange) c.Set("token", token) c.Next() } } func requirePasswordChanged() gin.HandlerFunc { return func(c *gin.Context) { if c.GetBool("must_change") { path := c.Request.URL.Path if path != "/api/password" && path != "/api/logout" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "首次登录必须修改密码", "must_change_password": true}) return } } c.Next() } } // RegisterRoutes 权限:viewer 只读;operator 专线运维;admin 用户/LDAP/插件/审计 func (s *Server) RegisterRoutes(r *gin.Engine) { r.Use(securityHeaders()) api := r.Group("/api") api.POST("/login", s.login) api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) auth := api.Group("") auth.Use(authMiddleware(), requirePasswordChanged()) { auth.POST("/logout", s.logout) auth.POST("/password", s.changePassword) auth.GET("/mfa/status", s.mfaStatus) auth.POST("/mfa/setup", s.mfaSetup) auth.POST("/mfa/enable", s.mfaEnable) auth.POST("/mfa/disable", s.mfaDisable) // 只读 auth.GET("/overview", s.overview) auth.GET("/readiness", s.readiness) auth.GET("/protocols", s.listProtocols) auth.GET("/tunnels", s.listTunnels) auth.GET("/tunnels/:id", s.getTunnel) auth.GET("/tunnels/:id/status", s.tunnelStatus) auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory) auth.GET("/tunnels/:id/policy", s.getTrafficPolicy) auth.GET("/ipsec/templates", s.ipsecTemplates) auth.GET("/nodes", s.listNodes) auth.GET("/plugins", s.listPlugins) auth.GET("/sessions", s.listSessions) auth.GET("/sessions/log", s.listConnectionLogs) auth.GET("/traffic/alerts", s.listTrafficAlerts) // 日志中心(分页、筛选、导出) auth.GET("/audit-logs", s.listAuditPaged) auth.GET("/connection-logs", s.listConnectionLogsPaged) auth.GET("/logs/stats", s.logStats) // 专线运维 operator+ ops := auth.Group("") ops.Use(requireRole("admin", "operator")) ops.POST("/tunnels", s.createTunnel) ops.PUT("/tunnels/:id", s.updateTunnel) ops.DELETE("/tunnels/:id", s.deleteTunnel) ops.POST("/tunnels/:id/start", s.startTunnel) ops.POST("/tunnels/:id/stop", s.stopTunnel) ops.POST("/tunnels/:id/test", s.testConnectivity) ops.GET("/tunnels/:id/logs", s.tunnelLogs) ops.POST("/ipsec/compare", s.ipsecCompare) ops.GET("/tunnels/:id/config", s.downloadConfig) ops.GET("/vpn-users/:id/config", s.downloadVPNUserConfig) ops.GET("/tunnels/:id/wireguard/peers", s.listWireGuardPeers) ops.POST("/tunnels/:id/wireguard/peers", s.addWireGuardPeer) ops.DELETE("/tunnels/:id/wireguard/peers/:peer_id", s.removeWireGuardPeer) ops.GET("/tunnels/:id/wireguard/peers/:peer_id/config", s.downloadWireGuardPeerConfig) ops.POST("/tunnels/:id/openvpn/pki", s.initializeOpenVPNPKI) ops.GET("/binaries/status", s.listBinaryStatus) ops.POST("/binaries/install/:name", s.installBinary) ops.GET("/binaries/install-hint/:name", s.binaryInstallHint) ops.PUT("/tunnels/:id/policy", s.saveTrafficPolicy) // 批量操作 & 任务管理 ops.POST("/tunnels/batch/start", s.batchTunnels(taskmgr.ActionStart)) ops.POST("/tunnels/batch/stop", s.batchTunnels(taskmgr.ActionStop)) ops.POST("/tunnels/batch/delete", s.batchTunnels(taskmgr.ActionDelete)) ops.POST("/tunnels/batch/policy", s.batchSetPolicy) ops.GET("/tasks", s.listTasks) ops.GET("/tasks/:id", s.getTask) // 平台管理 admin admin := auth.Group("") admin.Use(requireRole("admin")) // 主机环境体检与一键初始化(仅 admin) admin.GET("/system/env", s.systemEnv) admin.POST("/system/fix", s.systemFix) admin.GET("/users", s.listUsers) admin.POST("/users", s.createUser) admin.PUT("/users/:id", s.updateUser) admin.DELETE("/users/:id", s.deleteUser) admin.POST("/users/batch", s.batchCreateUsers) admin.POST("/users/import-ldap", s.importLDAPUsers) admin.GET("/ldap", s.getLDAPConfig) admin.PUT("/ldap", s.saveLDAPConfig) admin.POST("/ldap/test", s.testLDAP) admin.POST("/ldap/users", s.listLDAPUsers) admin.POST("/sessions/:id/disconnect", s.disconnectSession) admin.GET("/vpn-users", s.listVPNUsers) admin.POST("/vpn-users", s.createVPNUser) admin.POST("/vpn-users/import-platform", s.importPlatformVPNUsers) admin.POST("/vpn-users/batch-delete", s.batchDeleteVPNUsers) admin.PUT("/vpn-users/:id", s.updateVPNUser) admin.DELETE("/vpn-users/:id", s.deleteVPNUser) admin.POST("/vpn-users/:id/mfa/setup", s.vpnUserMFASetup) admin.POST("/vpn-users/:id/mfa/enable", s.vpnUserMFAEnable) admin.POST("/vpn-users/:id/mfa/disable", s.vpnUserMFADisable) admin.GET("/vpn-users/:id/mfa", s.vpnUserMFAStatus) admin.POST("/vpn-users/:id/mfa/pin", s.vpnUserMFAPin) admin.POST("/vpn-users/:id/mfa/backup-codes", s.vpnUserMFABackupCodes) admin.POST("/vpn-users/:id/mfa/unlock", s.vpnUserMFAUnlock) admin.GET("/vpn-users/:id/mfa/otp-secrets", s.vpnUserOTPSecretsLine) admin.POST("/nodes", s.createNode) admin.POST("/nodes/test", s.testNodeConnection) admin.PUT("/nodes/:id", s.updateNode) admin.DELETE("/nodes/:id", s.deleteNode) admin.POST("/nodes/:id/verify", s.verifyNode) admin.POST("/plugins", s.createPlugin) admin.PUT("/plugins/:id", s.updatePlugin) admin.DELETE("/plugins/:id", s.deletePlugin) admin.GET("/audit", s.listAudit) admin.GET("/audit-logs/export", s.exportAuditLogs) admin.GET("/connection-logs/export", s.exportConnectionLogs) admin.POST("/logs/cleanup", s.cleanupLogs) } } 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": "隧道不存在"}) return } switch t.Protocol { case "openvpn": conf, err := openvpn.New().GenerateUserConfig(t, &user) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=openvpn-%s.ovpn", user.Username)) c.Data(200, "application/x-openvpn-profile", []byte(conf)) case "l2tp", "ikev2": // 导出通用接入说明(含隧道级参数);用户密码不回显,需用户自知。 p, err := s.mgr.Get(t.Protocol) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } base, err := p.GenerateClientConfig(t) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } conf := base + fmt.Sprintf("\n----- 本账号 -----\n用户名: %s\n(密码为创建时填写的拨号密码,平台不回显)\n", user.Username) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s-%s.txt", t.Protocol, user.Username)) c.Data(200, "text/plain; charset=utf-8", []byte(conf)) default: c.JSON(400, gin.H{"error": "该协议隧道不支持拨号用户配置下载"}) } } func (s *Server) listWireGuardPeers(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } if t.Protocol != "wireguard" { c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 管理"}) return } cfg, err := store.TunnelConfig(t) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } resp := gin.H{"peers": cfg["peers"]} if st, ok := s.mgr.GetStatus(t.ID); ok { resp["runtime_peers"] = st.Peers } c.JSON(200, resp) } func (s *Server) addWireGuardPeer(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } if t.Protocol != "wireguard" { c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 管理"}) return } var req map[string]interface{} if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } wasRunning := t.State == "running" wg := wireguard.New() if wasRunning { _ = wg.Stop(t) } p, err := wg.AddPeer(t, req) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if wasRunning { if err := wg.Start(t); err != nil { t.State = "error" t.LastError = err.Error() store.DB.Model(t).Updates(map[string]interface{}{"state": t.State, "last_error": t.LastError}) c.JSON(500, gin.H{"error": "Peer 已保存但重启失败: " + err.Error(), "peer": p}) return } store.DB.Model(t).Updates(map[string]interface{}{"state": "running", "last_error": ""}) } store.Audit(c.GetString("username"), "add_wireguard_peer", t.Name, p.ID) c.JSON(200, p) } func (s *Server) removeWireGuardPeer(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } if t.Protocol != "wireguard" { c.JSON(400, gin.H{"error": "仅 WireGuard 隧道支持 Peer 管理"}) return } wasRunning := t.State == "running" wg := wireguard.New() if wasRunning { _ = wg.Stop(t) } if err := wg.RemovePeer(t, c.Param("peer_id")); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if wasRunning { if err := wg.Start(t); err != nil { store.DB.Model(t).Updates(map[string]interface{}{"state": "error", "last_error": err.Error()}) c.JSON(500, gin.H{"error": "Peer 已删除但重启失败: " + err.Error()}) return } store.DB.Model(t).Updates(map[string]interface{}{"state": "running", "last_error": ""}) } store.Audit(c.GetString("username"), "remove_wireguard_peer", t.Name, c.Param("peer_id")) c.JSON(200, gin.H{"ok": true}) } func (s *Server) downloadWireGuardPeerConfig(c *gin.Context) { 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 } failKey := c.ClientIP() + "|" + req.Username if remaining := loginLockRemaining(failKey); remaining > 0 { store.Audit(req.Username, "login_locked", c.ClientIP(), "账号锁定中") c.JSON(429, gin.H{"error": fmt.Sprintf("失败次数过多,账号已锁定,请 %.0f 分钟后再试", remaining.Minutes())}) return } user, err := s.authenticateUser(req.Username, req.Password) if err != nil { recordLoginFail(failKey) store.Audit(req.Username, "login_failed", c.ClientIP(), "用户名或密码错误") c.JSON(401, gin.H{"error": "用户名或密码错误"}) return } if !user.Enabled { store.Audit(req.Username, "login_failed", c.ClientIP(), "用户已禁用") c.JSON(403, gin.H{"error": "用户已禁用"}) return } if err := verifyMFA(user, req.MFACode); err != nil { recordLoginFail(failKey) store.Audit(req.Username, "login_failed", c.ClientIP(), "MFA 校验失败") c.JSON(401, gin.H{"error": err.Error(), "mfa_required": true}) return } resetLoginFail(failKey) store.Audit(user.Username, "login_success", c.ClientIP(), "") c.JSON(200, gin.H{ "token": issueToken(user), "username": user.Username, "role": user.Role, "must_change_password": user.MustChangePassword, }) } func (s *Server) logout(c *gin.Context) { revokeToken(c.GetString("token")) store.Audit(c.GetString("username"), "logout", c.ClientIP(), "") c.JSON(200, gin.H{"ok": true}) } func (s *Server) changePassword(c *gin.Context) { var req struct { Old string `json:"old_password"` New string `json:"new_password"` } if err := c.ShouldBindJSON(&req); err != nil || len(req.New) < 8 { c.JSON(400, gin.H{"error": "新密码至少 8 位"}) 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 } // 新旧密码不能相同(与 admin 重置规则一致:最短 8 位) if req.Old == req.New { c.JSON(400, gin.H{"error": "新密码不能与原密码相同"}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(req.New), bcrypt.DefaultCost) store.DB.Model(&user).Updates(map[string]interface{}{"password": string(hash), "must_change_password": false}) clearMustChange(user.ID) store.Audit(username, "change_password", username, "修改登录密码") c.JSON(200, gin.H{"ok": true}) } // ---- 总览 ---- 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, "online_sessions": sessionmgr.CountActive(), "rx_bytes": sum.Rx, "tx_bytes": sum.Tx, "protocol_distribution": dist, "expiring": expiring, }) } // readiness reports host prerequisites that directly affect VPN forwarding. func (s *Server) readiness(c *gin.Context) { forwarding := false if raw, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward"); err == nil { forwarding = strings.TrimSpace(string(raw)) == "1" } iface, ifaceErr := service.DefaultRouteInterface() c.JSON(200, gin.H{ "root": os.Geteuid() == 0, "ipv4_forwarding": forwarding, "default_interface": iface, "default_route_ready": ifaceErr == nil, }) } func (s *Server) listProtocols(c *gin.Context) { 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) listBinaryStatus(c *gin.Context) { c.JSON(200, binmgr.CheckAll()) } // installBinary 下载部署协议二进制(FRP / OpenVPN 支持从 GitHub Release 下载) func (s *Server) installBinary(c *gin.Context) { name := c.Param("name") // 合法协议白名单 validProtocols := map[string]bool{ "frp": true, "openvpn": true, "wireguard": true, "strongswan": true, "ipsec": true, "ikev2": true, "l2tp": true, } if !validProtocols[name] { c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的协议: %s", name)}) return } // 先检查是否已安装 st := binmgr.CheckBinary(binmgr.ProtocolAlias(name)) if st.Installed { c.JSON(200, gin.H{"ok": true, "version": st.Version, "message": "已安装", "source": st.Source}) return } // 策略1:支持 GitHub 下载的协议(FRP / OpenVPN)先尝试下载 if _, canDownload := binmgr.GetDeploySpec(name); canDownload { version, err := binmgr.DownloadBinary(name) if err == nil { store.Audit(c.GetString("username"), "install_binary", name, version) c.JSON(200, gin.H{"ok": true, "version": version, "method": "download"}) return } // GitHub 下载失败,降级到包管理器 log.Printf("[install] %s GitHub 下载失败(%v),尝试包管理器安装", name, err) } // 策略2:系统包管理器一键安装 output, err := binmgr.InstallViaPackageManager(name) if err != nil { c.JSON(500, gin.H{"error": err.Error(), "output": output}) return } // 安装成功,重新检测 st = binmgr.CheckBinary(binmgr.ProtocolAlias(name)) store.Audit(c.GetString("username"), "install_binary", name, st.Version) c.JSON(200, gin.H{"ok": true, "version": st.Version, "method": "package_manager", "output": output}) } // binaryInstallHint 获取协议的系统包安装命令 func (s *Server) binaryInstallHint(c *gin.Context) { name := c.Param("name") hint := binmgr.InstallHint(name) c.JSON(200, hint) } // ---- 在线会话与流量审计 ---- func (s *Server) listSessions(c *gin.Context) { c.JSON(200, sessionmgr.ListActive()) } func (s *Server) listConnectionLogs(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200")) c.JSON(200, sessionmgr.ListLogs(limit)) } func (s *Server) listTrafficAlerts(c *gin.Context) { c.JSON(200, trafficmgr.ListAlerts(50)) } // disconnectSession 踢指定会话下线(标记 disconnected + 审计日志) func (s *Server) disconnectSession(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var session model.VPNSession if err := store.DB.First(&session, id).Error; err != nil { c.JSON(404, gin.H{"error": "会话不存在"}) return } now := time.Now() store.DB.Model(&session).Updates(map[string]interface{}{ "status": "disconnected", "disconnected_at": now, }) sessionmgr.LogConnection(session.TunnelID, session.Username, "disconnect", session.RealAddress, "管理员手动踢下线") store.Audit(c.GetString("username"), "kick_session", session.Username, session.RealAddress) c.JSON(200, gin.H{"ok": true}) } // getTrafficPolicy 获取隧道的流量策略 func (s *Server) getTrafficPolicy(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) policy := trafficmgr.GetPolicy(uint(id)) c.JSON(200, policy) } // saveTrafficPolicy 设置流量配额/限速策略 func (s *Server) saveTrafficPolicy(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req model.TrafficPolicy if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } req.TunnelID = uint(id) if err := trafficmgr.SavePolicy(&req); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } store.Audit(c.GetString("username"), "set_policy", fmt.Sprintf("tunnel#%d", id), fmt.Sprintf("配额=%d 限速=%d 动作=%s", req.QuotaBytes, req.RateLimitMbps, req.ActionOnExceed)) c.JSON(200, gin.H{"ok": true}) } // ---- 专线管理 ---- func (s *Server) listTunnels(c *gin.Context) { 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, clientConfError string if p, err := s.mgr.Get(t.Protocol); err == nil { clientConf, err = p.GenerateClientConfig(t) if err != nil { clientConfError = err.Error() } } c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf, "client_config_error": clientConfError}) } func (s *Server) updateTunnel(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } mu := lockTunnel(t.ID) defer mu.Unlock() var req tunnelReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } hasCfg := req.Config != nil if !hasCfg { if req.Name != "" { t.Name = req.Name } t.Remark, t.AutoStart, t.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt 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 } store.Audit(c.GetString("username"), "update_tunnel", t.Name, "") c.JSON(200, t) return } 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) original := *t candidate := original candidate.Config = string(raw) if req.Name != "" { candidate.Name = req.Name } candidate.Remark, candidate.AutoStart, candidate.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt wasRunning := original.State == "running" backup, err := service.SnapshotInstance(&original) if err != nil { c.JSON(500, gin.H{"error": "无法备份旧配置,已取消更新: " + err.Error()}) return } defer service.CleanupInstanceSnapshot(backup) rollback := func(cause error) error { _ = service.RestoreInstanceSnapshot(&original, backup) if err := store.DB.Save(&original).Error; err != nil { return fmt.Errorf("恢复数据库配置失败: %w", err) } if wasRunning { if err := p.Start(&original); err != nil { return fmt.Errorf("恢复旧服务失败: %w", err) } } *t = original return cause } if wasRunning { if err := p.Stop(&original); err != nil { c.JSON(500, gin.H{"error": "停止旧配置失败: " + err.Error()}) return } } if err := p.Create(&candidate); err != nil { if restoreErr := rollback(err); restoreErr != err { c.JSON(500, gin.H{"error": "生成新配置失败,且回滚异常: " + restoreErr.Error()}) return } c.JSON(500, gin.H{"error": "生成新配置失败,已恢复旧配置: " + err.Error()}) return } if wasRunning { if err := p.Start(&candidate); err != nil { if restoreErr := rollback(err); restoreErr != err { c.JSON(500, gin.H{"error": "启动新配置失败,且回滚异常: " + restoreErr.Error()}) return } c.JSON(500, gin.H{"error": "启动新配置失败,已恢复旧服务: " + err.Error()}) return } candidate.State, candidate.LastError = "running", "" if err := store.DB.Model(&candidate).Updates(map[string]interface{}{"state": candidate.State, "last_error": ""}).Error; err != nil { _ = rollback(err) c.JSON(500, gin.H{"error": "保存运行状态失败,已恢复旧服务: " + err.Error()}) return } } *t = candidate store.Audit(c.GetString("username"), "update_tunnel", t.Name, "") c.JSON(200, t) } func (s *Server) deleteTunnel(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } mu := lockTunnel(t.ID) defer mu.Unlock() 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 } mu := lockTunnel(t.ID) defer mu.Unlock() 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 } mu := lockTunnel(t.ID) defer mu.Unlock() p, err := s.mgr.Get(t.Protocol) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) 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 } store.Audit(c.GetString("username"), "download_config", t.Name, "下载客户端配置(可能含私钥/PSK)") ext := "conf" switch t.Protocol { case "openvpn": ext = "ovpn" case "frp": ext = "toml" case "ipsec", "l2tp", "ikev2": 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) } // tunnelTrafficHistory 返回隧道流量时间序列数据 // granularity: minute(最近 1 小时,每分钟聚合), hour(默认,最近 24 小时,每小时聚合), day(最近 30 天,每天聚合) // hours: 覆盖时间范围(默认 24) func (s *Server) tunnelTrafficHistory(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24")) if hours < 1 { hours = 1 } if hours > 720 { // max 30 days hours = 720 } granularity := c.DefaultQuery("granularity", "hour") if granularity != "minute" && granularity != "hour" && granularity != "day" { granularity = "hour" } since := time.Now().Add(-time.Duration(hours) * time.Hour) type row struct { Time time.Time `json:"time"` Rx int64 `json:"rx"` Tx int64 `json:"tx"` DeltaRx int64 `json:"delta_rx"` DeltaTx int64 `json:"delta_tx"` } var results []row switch granularity { case "minute": // Group by minute for fine granularity (last 1 hour typical) var raw []model.TrafficSample store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since). Order("created_at asc").Find(&raw) // Bucket by minute buckets := map[time.Time]*row{} for _, s := range raw { key := s.CreatedAt.Truncate(time.Minute) if _, ok := buckets[key]; !ok { buckets[key] = &row{Time: key} } b := buckets[key] b.Rx = s.RxBytes // last value in bucket wins b.Tx = s.TxBytes b.DeltaRx += s.DeltaRx b.DeltaTx += s.DeltaTx } for _, v := range buckets { results = append(results, *v) } case "day": // Group by day var raw []model.TrafficSample store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since). Order("created_at asc").Find(&raw) buckets := map[time.Time]*row{} for _, s := range raw { key := time.Date(s.CreatedAt.Year(), s.CreatedAt.Month(), s.CreatedAt.Day(), 0, 0, 0, 0, s.CreatedAt.Location()) if _, ok := buckets[key]; !ok { buckets[key] = &row{Time: key} } b := buckets[key] b.Rx = s.RxBytes b.Tx = s.TxBytes b.DeltaRx += s.DeltaRx b.DeltaTx += s.DeltaTx } for _, v := range buckets { results = append(results, *v) } default: // "hour" var raw []model.TrafficSample store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since). Order("created_at asc").Find(&raw) buckets := map[time.Time]*row{} for _, s := range raw { key := s.CreatedAt.Truncate(time.Hour) if _, ok := buckets[key]; !ok { buckets[key] = &row{Time: key} } b := buckets[key] b.Rx = s.RxBytes b.Tx = s.TxBytes b.DeltaRx += s.DeltaRx b.DeltaTx += s.DeltaTx } for _, v := range buckets { results = append(results, *v) } } if results == nil { results = []row{} } c.JSON(200, gin.H{ "tunnel_id": t.ID, "granularity": granularity, "hours": hours, "samples": results, }) } // tunnelLogs 通过 SSE 实时推送专线实例日志。 // 读取 /instances/-/instance.log,先发送尾部 100 行历史,再 tail 新增行。 func (s *Server) tunnelLogs(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { return } logPath := filepath.Join(service.InstanceDir(t), "instance.log") // SSE headers c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("X-Accel-Buffering", "no") // Nginx 透传 flusher, ok := c.Writer.(http.Flusher) if !ok { c.JSON(500, gin.H{"error": "streaming not supported"}) return } // 如果日志文件不存在,发送提示 if _, err := os.Stat(logPath); err != nil { msg := "⚠ 该专线暂无日志文件(可能未启动或协议无 daemon 进程,如 WireGuard/IPSec)" c.Writer.Write([]byte("data: " + sseEscape(msg) + "\n\n")) flusher.Flush() return } // 1) 发送尾部历史日志(最多 100 行) histLines := readTailLines(logPath, 100) for _, line := range histLines { c.Writer.Write([]byte("data: " + sseEscape(line) + "\n\n")) } flusher.Flush() // 2) 记录当前文件大小,后续只读新增部分 fi, _ := os.Stat(logPath) offset := int64(0) if fi != nil { offset = fi.Size() } // 3) 轮询新增内容(每 1s),直到客户端断开 notify := c.Writer.CloseNotify() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-notify: return // 客户端断开 case <-ticker.C: newData, newOffset, err := readFromOffset(logPath, offset) if err != nil || len(newData) == 0 { continue } offset = newOffset for _, line := range newData { c.Writer.Write([]byte("data: " + sseEscape(line) + "\n\n")) } flusher.Flush() case <-time.After(30 * time.Second): // 心跳保活 c.Writer.Write([]byte(": heartbeat\n\n")) flusher.Flush() } } } // sseEscape 转义 SSE data 行中的换行(多行日志拆成单独的 data: 行) func sseEscape(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") lines := strings.Split(s, "\n") return strings.Join(lines, "\ndata: ") } // readTailLines 读取文件最后 n 行 func readTailLines(path string, n int) []string { f, err := os.Open(path) if err != nil { return nil } defer f.Close() var all []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) for scanner.Scan() { all = append(all, scanner.Text()) } if len(all) > n { return all[len(all)-n:] } return all } // readFromOffset 从 offset 处读取新增内容,返回按行拆分的结果和新 offset func readFromOffset(path string, offset int64) ([]string, int64, error) { f, err := os.Open(path) if err != nil { return nil, offset, err } defer f.Close() if _, err := f.Seek(offset, 0); err != nil { return nil, offset, err } scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) var lines []string for scanner.Scan() { lines = append(lines, scanner.Text()) } fi, _ := f.Stat() newOffset := offset if fi != nil { newOffset = fi.Size() } return lines, newOffset, nil } func (s *Server) testConnectivity(c *gin.Context) { t, err := s.mustTunnel(c) if err != nil { 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}) } // ---- 节点管理(支持 SSH 凭证验证)---- // nodeReq 节点创建/更新请求(含明文凭证,仅传输用,不入库明文) type nodeReq struct { Name string `json:"name"` Address string `json:"address"` Port int `json:"port"` Kind string `json:"kind"` Username string `json:"username"` AuthType string `json:"auth_type"` Password string `json:"password"` PrivateKey string `json:"private_key"` Remark string `json:"remark"` } // nodeTestReq 临时连接测试请求(不落库) type nodeTestReq struct { Address string `json:"address"` Port int `json:"port"` Username string `json:"username"` AuthType string `json:"auth_type"` Password string `json:"password"` PrivateKey string `json:"private_key"` } func (s *Server) listNodes(c *gin.Context) { var nodes []model.Node store.DB.Order("kind asc, id asc").Find(&nodes) c.JSON(200, nodes) } func (s *Server) createNode(c *gin.Context) { var req nodeReq if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" || req.Address == "" { c.JSON(400, gin.H{"error": "节点名称与地址必填"}) return } if req.Port == 0 { req.Port = 22 } if req.Kind == "" { req.Kind = "remote" } if req.AuthType == "" { req.AuthType = "password" } n := model.Node{ Name: req.Name, Address: req.Address, Port: req.Port, Kind: req.Kind, Username: req.Username, AuthType: req.AuthType, Remark: req.Remark, Status: "unknown", } // 敏感凭证加密存储 if req.Password != "" || req.PrivateKey != "" { sec := map[string]interface{}{"password": req.Password, "private_key": req.PrivateKey} raw, _ := json.Marshal(sec) enc, err := store.EncryptSecret(string(raw)) if err != nil { c.JSON(500, gin.H{"error": "凭证加密失败"}) return } n.Secrets = enc } if err := store.DB.Create(&n).Error; err != nil { c.JSON(400, gin.H{"error": "保存失败(名称可能重复): " + err.Error()}) return } store.Audit(c.GetString("username"), "create_node", n.Name, fmt.Sprintf("%s:%d", n.Address, n.Port)) c.JSON(200, n) } func (s *Server) updateNode(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var n model.Node if err := store.DB.First(&n, id).Error; err != nil { c.JSON(404, gin.H{"error": "节点不存在"}) return } var req nodeReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } if req.Name != "" { n.Name = req.Name } if req.Address != "" { n.Address = req.Address } if req.Port > 0 { n.Port = req.Port } if req.Kind != "" { n.Kind = req.Kind } n.Username = req.Username if req.AuthType != "" { n.AuthType = req.AuthType } n.Remark = req.Remark // 仅当传入了新凭证时更新(空值表示保持不变) if req.Password != "" || req.PrivateKey != "" { sec := map[string]interface{}{"password": req.Password, "private_key": req.PrivateKey} raw, _ := json.Marshal(sec) enc, err := store.EncryptSecret(string(raw)) if err != nil { c.JSON(500, gin.H{"error": "凭证加密失败"}) return } n.Secrets = enc n.Status = "unknown" // 凭证变更后重置状态 } store.DB.Save(&n) store.Audit(c.GetString("username"), "update_node", n.Name, "") c.JSON(200, n) } // testNodeConnection 临时凭证测试(不落库),用于添加节点对话框的即时验证 func (s *Server) testNodeConnection(c *gin.Context) { var req nodeTestReq if err := c.ShouldBindJSON(&req); err != nil || req.Address == "" { c.JSON(400, gin.H{"error": "地址必填"}) return } result := sshutil.TestConnection(sshutil.Config{ Host: req.Address, Port: req.Port, User: req.Username, AuthType: req.AuthType, Password: req.Password, KeyData: req.PrivateKey, }) c.JSON(200, result) } // verifyNode 用已存节点的加密凭证做 SSH 测试,更新 status func (s *Server) verifyNode(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var n model.Node if err := store.DB.First(&n, id).Error; err != nil { c.JSON(404, gin.H{"error": "节点不存在"}) return } if n.Kind == "local" { c.JSON(200, gin.H{"success": true, "message": "本机节点,无需 SSH 验证"}) return } // 解密凭证 password, privateKey := decodeNodeSecrets(n.Secrets) result := sshutil.TestConnection(sshutil.Config{ Host: n.Address, Port: n.Port, User: n.Username, AuthType: n.AuthType, Password: password, KeyData: privateKey, }) if result.Success { n.Status = "verified" } else { n.Status = "failed" } store.DB.Model(&n).Update("status", n.Status) store.Audit(c.GetString("username"), "verify_node", n.Name, fmt.Sprintf("success=%v", result.Success)) c.JSON(200, result) } func (s *Server) deleteNode(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var n model.Node if err := store.DB.First(&n, id).Error; err != nil { c.JSON(404, gin.H{"error": "节点不存在"}) return } // 检查是否有专线引用该节点 var refCount int64 store.DB.Model(&model.Tunnel{}).Where("node_id = ?", id).Count(&refCount) if refCount > 0 { c.JSON(400, gin.H{"error": fmt.Sprintf("该节点被 %d 条专线引用,请先解除关联", refCount)}) return } store.DB.Delete(&model.Node{}, id) store.Audit(c.GetString("username"), "delete_node", n.Name, "") c.JSON(200, gin.H{"ok": true}) } // decodeNodeSecrets 解密节点的 SSH 凭证 JSON func decodeNodeSecrets(enc string) (password, privateKey string) { plain, err := store.DecryptSecret(enc) if err != nil || plain == "" { return "", "" } var sec map[string]interface{} if json.Unmarshal([]byte(plain), &sec) != nil { return "", "" } if v, ok := sec["password"].(string); ok { password = v } if v, ok := sec["private_key"].(string); ok { privateKey = v } return } // ---- 自定义协议插件 ---- func (s *Server) listPlugins(c *gin.Context) { 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 }