1760 lines
52 KiB
Go
1760 lines
52 KiB
Go
// 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/identityprovider"
|
||
"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
|
||
router *gin.Engine
|
||
gatewayMu sync.Mutex
|
||
}
|
||
|
||
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) {
|
||
s.router = r
|
||
r.Use(securityHeaders())
|
||
idp := identityprovider.New()
|
||
id := r.Group("/id", identityprovider.SecureGateway(), identityprovider.RateLimit())
|
||
id.GET("/.well-known/openid-configuration", idp.Discovery)
|
||
id.GET("/jwks", idp.JWKS)
|
||
id.GET("/authorize", idp.Authorize)
|
||
id.GET("/feishu/callback", idp.FeishuCallback)
|
||
id.POST("/token", idp.Token)
|
||
id.GET("/userinfo", idp.UserInfo)
|
||
api := r.Group("/api")
|
||
api.POST("/login", s.login)
|
||
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
|
||
// 终端用户客户端 API(桌面/移动端):独立令牌,与管理会话隔离
|
||
api.POST("/client/login", s.clientLogin)
|
||
api.GET("/client/capabilities", func(c *gin.Context) { c.JSON(200, gin.H{"password": true, "oidc": false, "wireguard": true}) })
|
||
cli := api.Group("/client", clientAuth())
|
||
{
|
||
cli.POST("/logout", s.clientLogout)
|
||
cli.GET("/lines", s.clientLines)
|
||
cli.GET("/devices", s.clientListDevices)
|
||
cli.POST("/devices", s.clientRegisterDevice)
|
||
cli.GET("/devices/:id/config", s.clientDeviceConfig)
|
||
cli.GET("/devices/:id/status", s.clientDeviceStatus)
|
||
cli.DELETE("/devices/:id", s.clientRevokeDevice)
|
||
}
|
||
|
||
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/openvpn/oidc-port", s.nextOIDCPort)
|
||
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.GET("/tunnels/openvpn/oidc-readiness", s.openvpnOIDCReadiness)
|
||
|
||
// 批量操作 & 任务管理
|
||
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.GET("/system/alerts", s.getAlertSettings)
|
||
admin.PUT("/system/alerts", s.saveAlertSettings)
|
||
admin.DELETE("/system/alerts", s.clearAlertSettings)
|
||
admin.POST("/system/alerts/test", s.testAlertSettings)
|
||
admin.POST("/system/fix", s.systemFix)
|
||
admin.GET("/system/tls", s.tlsStatus)
|
||
admin.GET("/system/identity", idp.Status)
|
||
admin.GET("/system/identity/profiles", idp.Profiles)
|
||
admin.POST("/system/identity/directory-check", idp.DirectoryCheck)
|
||
admin.GET("/system/identity/directory-sync", idp.DirectorySyncStatus)
|
||
admin.GET("/system/identity/directory-sync/check", idp.DirectorySyncCheck)
|
||
admin.PUT("/system/identity/directory-sync", idp.DirectorySyncConfigure)
|
||
admin.POST("/system/identity/directory-sync/run", idp.DirectorySyncRun)
|
||
admin.GET("/tunnels/:id/feishu-grants", idp.Grants)
|
||
admin.POST("/tunnels/:id/feishu-grants", idp.AddGrant)
|
||
admin.DELETE("/tunnels/:id/feishu-grants/:grant_id", idp.DeleteGrant)
|
||
admin.POST("/system/identity/tenant", idp.QueryTenant)
|
||
admin.PUT("/system/identity", idp.Configure)
|
||
admin.POST("/system/identity/gateway", identityprovider.SecureGateway(), s.identityGateway)
|
||
admin.PUT("/system/tls/domain", s.setTLSDomain)
|
||
admin.PUT("/system/tls/mode", s.setTLSMode)
|
||
admin.GET("/system/tls/nginx-preview", s.nginxPreview)
|
||
admin.GET("/system/tls/nginx-setup", s.nginxSetupStatus)
|
||
admin.POST("/system/tls/nginx-setup", s.initializeNginx)
|
||
admin.POST("/system/tls/nginx-activate", s.activateNginx)
|
||
admin.POST("/system/tls/certificate", s.uploadTLSCertificate)
|
||
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.GET("/client-devices", s.adminListClientDevices)
|
||
admin.POST("/client-devices/:id/revoke", s.adminRevokeClientDevice)
|
||
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)
|
||
if t.State == "running" {
|
||
if err := s.syncAuthGatewayForTunnel(&t); err != nil {
|
||
c.Header("X-Opsx-Gateway-Warning", "route-sync-failed")
|
||
log.Printf("专线 %d 已启动但认证入口同步失败: %v", t.ID, err)
|
||
}
|
||
}
|
||
}
|
||
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
|
||
if wasRunning {
|
||
if err := s.syncAuthGatewayForTunnel(t); err != nil {
|
||
c.Header("X-Opsx-Gateway-Warning", "route-sync-failed")
|
||
log.Printf("专线 %d 已更新但认证入口同步失败: %v", t.ID, err)
|
||
}
|
||
}
|
||
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)
|
||
if err := s.syncAuthGatewayForTunnel(t); err != nil {
|
||
c.Header("X-Opsx-Gateway-Warning", "route-sync-failed")
|
||
log.Printf("专线 %d 已删除但认证入口同步失败: %v", t.ID, err)
|
||
}
|
||
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" {
|
||
if err := s.syncAuthGatewayForTunnel(t); err != nil {
|
||
c.JSON(200, gin.H{"ok": true, "state": "running", "warning": "专线运行中,但公网认证入口同步失败:" + err.Error()})
|
||
return
|
||
}
|
||
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)
|
||
s.mgr.RemoveStatus(t.ID)
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
t.State = "running"
|
||
t.LastError = ""
|
||
store.DB.Save(t)
|
||
s.mgr.RemoveStatus(t.ID) // the monitor's previous snapshot can lag behind this transition
|
||
store.Audit(c.GetString("username"), "start_tunnel", t.Name, "")
|
||
if err := s.syncAuthGatewayForTunnel(t); err != nil {
|
||
c.JSON(200, gin.H{"ok": true, "state": "running", "warning": "专线已启动,但公网认证入口同步失败:" + err.Error()})
|
||
return
|
||
}
|
||
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)
|
||
s.mgr.RemoveStatus(t.ID)
|
||
store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "")
|
||
if err := s.syncAuthGatewayForTunnel(t); err != nil {
|
||
c.JSON(200, gin.H{"ok": true, "state": "stopped", "warning": "专线已停止,但公网认证入口同步失败:" + err.Error()})
|
||
return
|
||
}
|
||
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 实时推送专线实例日志。
|
||
// 读取 <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
|
||
}
|