Files
opsx-vpn/backend/internal/api/api.go
T

782 lines
21 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 (
"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
}