Files
opsx-vpn/backend/internal/api/api.go
T
Logan.Li 1e2f8ef24f feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
2026-07-23 01:42:36 +08:00

1463 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api 提供 REST API 路由与处理器
package api
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"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/trafficmgr"
)
// 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
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("/protocols", s.listProtocols)
auth.GET("/tunnels", s.listTunnels)
auth.GET("/tunnels/:id", s.getTunnel)
auth.GET("/tunnels/:id/status", s.tunnelStatus)
auth.GET("/tunnels/:id/policy", s.getTrafficPolicy)
auth.GET("/ipsec/templates", s.ipsecTemplates)
auth.GET("/nodes", s.listNodes)
auth.GET("/plugins", s.listPlugins)
auth.GET("/sessions", s.listSessions)
auth.GET("/sessions/log", s.listConnectionLogs)
auth.GET("/traffic/alerts", s.listTrafficAlerts)
// 专线运维 operator+
ops := auth.Group("")
ops.Use(requireRole("admin", "operator"))
ops.POST("/tunnels", s.createTunnel)
ops.PUT("/tunnels/:id", s.updateTunnel)
ops.DELETE("/tunnels/:id", s.deleteTunnel)
ops.POST("/tunnels/:id/start", s.startTunnel)
ops.POST("/tunnels/:id/stop", s.stopTunnel)
ops.POST("/tunnels/:id/test", s.testConnectivity)
ops.GET("/tunnels/:id/logs", s.tunnelLogs)
ops.POST("/ipsec/compare", s.ipsecCompare)
ops.GET("/tunnels/:id/config", s.downloadConfig)
ops.GET("/vpn-users/:id/config", s.downloadVPNUserConfig)
ops.GET("/tunnels/:id/wireguard/peers", s.listWireGuardPeers)
ops.POST("/tunnels/:id/wireguard/peers", s.addWireGuardPeer)
ops.DELETE("/tunnels/:id/wireguard/peers/:peer_id", s.removeWireGuardPeer)
ops.GET("/tunnels/:id/wireguard/peers/:peer_id/config", s.downloadWireGuardPeerConfig)
ops.POST("/tunnels/:id/openvpn/pki", s.initializeOpenVPNPKI)
ops.GET("/binaries/status", s.listBinaryStatus)
ops.POST("/binaries/install/:name", s.installBinary)
ops.GET("/binaries/install-hint/:name", s.binaryInstallHint)
ops.PUT("/tunnels/:id/policy", s.saveTrafficPolicy)
// 平台管理 admin
admin := auth.Group("")
admin.Use(requireRole("admin"))
admin.GET("/users", s.listUsers)
admin.POST("/users", s.createUser)
admin.PUT("/users/:id", s.updateUser)
admin.DELETE("/users/:id", s.deleteUser)
admin.POST("/users/batch", s.batchCreateUsers)
admin.POST("/users/import-ldap", s.importLDAPUsers)
admin.GET("/ldap", s.getLDAPConfig)
admin.PUT("/ldap", s.saveLDAPConfig)
admin.POST("/ldap/test", s.testLDAP)
admin.POST("/ldap/users", s.listLDAPUsers)
admin.POST("/sessions/:id/disconnect", s.disconnectSession)
admin.GET("/vpn-users", s.listVPNUsers)
admin.POST("/vpn-users", s.createVPNUser)
admin.PUT("/vpn-users/:id", s.updateVPNUser)
admin.DELETE("/vpn-users/:id", s.deleteVPNUser)
admin.POST("/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)
}
}
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,
})
}
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")
if name != "frp" && name != "openvpn" {
c.JSON(400, gin.H{"error": "仅 FRP / OpenVPN 支持平台自动下载部署,其他协议请使用系统包管理器安装"})
return
}
version, err := binmgr.DownloadBinary(name)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "install_binary", name, version)
c.JSON(200, gin.H{"ok": true, "version": version})
}
// binaryInstallHint 获取协议的系统包安装命令
func (s *Server) binaryInstallHint(c *gin.Context) {
name := c.Param("name")
hint := binmgr.InstallHint(name)
c.JSON(200, hint)
}
// ---- 在线会话与流量审计 ----
func (s *Server) listSessions(c *gin.Context) {
c.JSON(200, sessionmgr.ListActive())
}
func (s *Server) listConnectionLogs(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
c.JSON(200, sessionmgr.ListLogs(limit))
}
func (s *Server) listTrafficAlerts(c *gin.Context) {
c.JSON(200, trafficmgr.ListAlerts(50))
}
// disconnectSession 踢指定会话下线(标记 disconnected + 审计日志)
func (s *Server) disconnectSession(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var session model.VPNSession
if err := store.DB.First(&session, id).Error; err != nil {
c.JSON(404, gin.H{"error": "会话不存在"})
return
}
now := time.Now()
store.DB.Model(&session).Updates(map[string]interface{}{
"status": "disconnected",
"disconnected_at": now,
})
sessionmgr.LogConnection(session.TunnelID, session.Username, "disconnect", session.RealAddress,
"管理员手动踢下线")
store.Audit(c.GetString("username"), "kick_session", session.Username, session.RealAddress)
c.JSON(200, gin.H{"ok": true})
}
// getTrafficPolicy 获取隧道的流量策略
func (s *Server) getTrafficPolicy(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
policy := trafficmgr.GetPolicy(uint(id))
c.JSON(200, policy)
}
// saveTrafficPolicy 设置流量配额/限速策略
func (s *Server) saveTrafficPolicy(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req model.TrafficPolicy
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
req.TunnelID = uint(id)
if err := trafficmgr.SavePolicy(&req); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "set_policy", fmt.Sprintf("tunnel#%d", id),
fmt.Sprintf("配额=%d 限速=%d 动作=%s", req.QuotaBytes, req.RateLimitMbps, req.ActionOnExceed))
c.JSON(200, gin.H{"ok": true})
}
// ---- 专线管理 ----
func (s *Server) listTunnels(c *gin.Context) {
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
}
mu := lockTunnel(t.ID)
defer mu.Unlock()
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
}
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)
}
// tunnelLogs 通过 SSE 实时推送专线实例日志。
// 读取 <data>/instances/<id>-<protocol>/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
}