后端: - trafficmgr: 实现 tc htb 限速(ApplyRateLimit/RemoveRateLimit),自动推导接口名 - 各协议服务: 代码质量改进与错误处理增强 前端: - protocols.js: 170个硬编码中文字符串全部转为 i18n key,中英文双语翻译 - 提取 fmtBytes() 到 utils/format.js 公共模块(消除 4 处重复) - router.js: 添加路由级权限守卫,非管理员无法直接 URL 访问管理页 - Tunnels.vue: SSE 日志流改用 fetch+ReadableStream,token 放 Authorization header - VPNUsers.vue: 修复 catch 块中文硬编码 fallback - 新增 TaskProgress 组件、taskmgr 后端任务管理
1504 lines
43 KiB
Go
1504 lines
43 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/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"
|
||
"vpn-platform/backend/internal/taskmgr"
|
||
)
|
||
|
||
// Server API 服务器
|
||
type Server struct {
|
||
mgr *core.Manager
|
||
taskMgr *taskmgr.Manager
|
||
}
|
||
|
||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} }
|
||
|
||
// ---- Token 会话(内存态,定期清理过期项)----
|
||
var (
|
||
sessions = map[string]sessionInfo{}
|
||
sessionsMu sync.RWMutex
|
||
)
|
||
|
||
type sessionInfo struct {
|
||
username string
|
||
userID uint
|
||
role string
|
||
mustChange bool
|
||
expireAt time.Time
|
||
}
|
||
|
||
func init() {
|
||
go func() {
|
||
ticker := time.NewTicker(10 * time.Minute)
|
||
for range ticker.C {
|
||
now := time.Now()
|
||
sessionsMu.Lock()
|
||
for token, sess := range sessions {
|
||
if now.After(sess.expireAt) {
|
||
delete(sessions, token)
|
||
}
|
||
}
|
||
sessionsMu.Unlock()
|
||
loginMu.Lock()
|
||
for key, rec := range loginFails {
|
||
if now.After(rec.lockedUntil) && now.Sub(rec.lastFail) > 30*time.Minute {
|
||
delete(loginFails, key)
|
||
}
|
||
}
|
||
loginMu.Unlock()
|
||
}
|
||
}()
|
||
}
|
||
|
||
func issueToken(user *model.User) string {
|
||
raw := make([]byte, 32)
|
||
_, _ = rand.Read(raw)
|
||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||
sessionsMu.Lock()
|
||
sessions[token] = sessionInfo{username: user.Username, userID: user.ID, role: user.Role, mustChange: user.MustChangePassword, expireAt: time.Now().Add(24 * time.Hour)}
|
||
sessionsMu.Unlock()
|
||
return token
|
||
}
|
||
|
||
func revokeToken(token string) {
|
||
sessionsMu.Lock()
|
||
delete(sessions, token)
|
||
sessionsMu.Unlock()
|
||
}
|
||
|
||
func clearMustChange(userID uint) {
|
||
sessionsMu.Lock()
|
||
for token, sess := range sessions {
|
||
if sess.userID == userID {
|
||
sess.mustChange = false
|
||
sessions[token] = sess
|
||
}
|
||
}
|
||
sessionsMu.Unlock()
|
||
}
|
||
|
||
// ---- 登录防爆破:同 IP+账号 连续失败 5 次锁定 10 分钟 ----
|
||
var (
|
||
loginFails = map[string]*loginFailRecord{}
|
||
loginMu sync.Mutex
|
||
)
|
||
|
||
type loginFailRecord struct {
|
||
fails int
|
||
lastFail time.Time
|
||
lockedUntil time.Time
|
||
}
|
||
|
||
func loginLockRemaining(key string) time.Duration {
|
||
loginMu.Lock()
|
||
defer loginMu.Unlock()
|
||
if rec, ok := loginFails[key]; ok && time.Now().Before(rec.lockedUntil) {
|
||
return time.Until(rec.lockedUntil)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func recordLoginFail(key string) {
|
||
loginMu.Lock()
|
||
defer loginMu.Unlock()
|
||
rec, ok := loginFails[key]
|
||
if !ok {
|
||
rec = &loginFailRecord{}
|
||
loginFails[key] = rec
|
||
}
|
||
rec.fails++
|
||
rec.lastFail = time.Now()
|
||
if rec.fails >= 5 {
|
||
rec.lockedUntil = time.Now().Add(10 * time.Minute)
|
||
rec.fails = 0
|
||
}
|
||
}
|
||
|
||
func resetLoginFail(key string) {
|
||
loginMu.Lock()
|
||
delete(loginFails, key)
|
||
loginMu.Unlock()
|
||
}
|
||
|
||
// ---- 专线操作互斥锁 ----
|
||
var tunnelLocks sync.Map
|
||
|
||
func lockTunnel(id uint) *sync.Mutex {
|
||
v, _ := tunnelLocks.LoadOrStore(id, &sync.Mutex{})
|
||
mu := v.(*sync.Mutex)
|
||
mu.Lock()
|
||
return mu
|
||
}
|
||
|
||
func securityHeaders() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
h := c.Writer.Header()
|
||
h.Set("X-Content-Type-Options", "nosniff")
|
||
h.Set("X-Frame-Options", "DENY")
|
||
h.Set("Referrer-Policy", "no-referrer")
|
||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'")
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func authMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := c.GetHeader("Authorization")
|
||
token = strings.TrimPrefix(token, "Bearer ")
|
||
// SSE (EventSource) 不支持自定义 header,降级从 query 参数读 token
|
||
if token == "" {
|
||
token = c.Query("token")
|
||
}
|
||
sessionsMu.RLock()
|
||
sess, ok := sessions[token]
|
||
sessionsMu.RUnlock()
|
||
if !ok || time.Now().After(sess.expireAt) {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录或会话已过期"})
|
||
return
|
||
}
|
||
c.Set("username", sess.username)
|
||
c.Set("user_id", sess.userID)
|
||
c.Set("role", sess.role)
|
||
c.Set("must_change", sess.mustChange)
|
||
c.Set("token", token)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func requirePasswordChanged() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if c.GetBool("must_change") {
|
||
path := c.Request.URL.Path
|
||
if path != "/api/password" && path != "/api/logout" {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "首次登录必须修改密码", "must_change_password": true})
|
||
return
|
||
}
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// RegisterRoutes 权限:viewer 只读;operator 专线运维;admin 用户/LDAP/插件/审计
|
||
func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||
r.Use(securityHeaders())
|
||
api := r.Group("/api")
|
||
api.POST("/login", s.login)
|
||
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
|
||
auth := api.Group("")
|
||
auth.Use(authMiddleware(), requirePasswordChanged())
|
||
{
|
||
auth.POST("/logout", s.logout)
|
||
auth.POST("/password", s.changePassword)
|
||
auth.GET("/mfa/status", s.mfaStatus)
|
||
auth.POST("/mfa/setup", s.mfaSetup)
|
||
auth.POST("/mfa/enable", s.mfaEnable)
|
||
auth.POST("/mfa/disable", s.mfaDisable)
|
||
|
||
// 只读
|
||
auth.GET("/overview", s.overview)
|
||
auth.GET("/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)
|
||
|
||
// 批量操作 & 任务管理
|
||
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.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")
|
||
|
||
// 合法协议白名单
|
||
validProtocols := map[string]bool{
|
||
"frp": true, "openvpn": true, "wireguard": true,
|
||
"strongswan": true, "ipsec": true, "ikev2": true, "l2tp": true,
|
||
}
|
||
if !validProtocols[name] {
|
||
c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的协议: %s", name)})
|
||
return
|
||
}
|
||
|
||
// 先检查是否已安装
|
||
st := binmgr.CheckBinary(binmgr.ProtocolAlias(name))
|
||
if st.Installed {
|
||
c.JSON(200, gin.H{"ok": true, "version": st.Version, "message": "已安装", "source": st.Source})
|
||
return
|
||
}
|
||
|
||
// 策略1:支持 GitHub 下载的协议(FRP / OpenVPN)先尝试下载
|
||
if _, canDownload := binmgr.GetDeploySpec(name); canDownload {
|
||
version, err := binmgr.DownloadBinary(name)
|
||
if err == nil {
|
||
store.Audit(c.GetString("username"), "install_binary", name, version)
|
||
c.JSON(200, gin.H{"ok": true, "version": version, "method": "download"})
|
||
return
|
||
}
|
||
// GitHub 下载失败,降级到包管理器
|
||
log.Printf("[install] %s GitHub 下载失败(%v),尝试包管理器安装", name, err)
|
||
}
|
||
|
||
// 策略2:系统包管理器一键安装
|
||
output, err := binmgr.InstallViaPackageManager(name)
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error(), "output": output})
|
||
return
|
||
}
|
||
|
||
// 安装成功,重新检测
|
||
st = binmgr.CheckBinary(binmgr.ProtocolAlias(name))
|
||
store.Audit(c.GetString("username"), "install_binary", name, st.Version)
|
||
c.JSON(200, gin.H{"ok": true, "version": st.Version, "method": "package_manager", "output": output})
|
||
}
|
||
|
||
// binaryInstallHint 获取协议的系统包安装命令
|
||
func (s *Server) binaryInstallHint(c *gin.Context) {
|
||
name := c.Param("name")
|
||
hint := binmgr.InstallHint(name)
|
||
c.JSON(200, hint)
|
||
}
|
||
|
||
// ---- 在线会话与流量审计 ----
|
||
|
||
func (s *Server) listSessions(c *gin.Context) {
|
||
c.JSON(200, sessionmgr.ListActive())
|
||
}
|
||
|
||
func (s *Server) listConnectionLogs(c *gin.Context) {
|
||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
|
||
c.JSON(200, sessionmgr.ListLogs(limit))
|
||
}
|
||
|
||
func (s *Server) listTrafficAlerts(c *gin.Context) {
|
||
c.JSON(200, trafficmgr.ListAlerts(50))
|
||
}
|
||
|
||
// disconnectSession 踢指定会话下线(标记 disconnected + 审计日志)
|
||
func (s *Server) disconnectSession(c *gin.Context) {
|
||
id, _ := strconv.Atoi(c.Param("id"))
|
||
var session model.VPNSession
|
||
if err := store.DB.First(&session, id).Error; err != nil {
|
||
c.JSON(404, gin.H{"error": "会话不存在"})
|
||
return
|
||
}
|
||
now := time.Now()
|
||
store.DB.Model(&session).Updates(map[string]interface{}{
|
||
"status": "disconnected",
|
||
"disconnected_at": now,
|
||
})
|
||
sessionmgr.LogConnection(session.TunnelID, session.Username, "disconnect", session.RealAddress,
|
||
"管理员手动踢下线")
|
||
store.Audit(c.GetString("username"), "kick_session", session.Username, session.RealAddress)
|
||
c.JSON(200, gin.H{"ok": true})
|
||
}
|
||
|
||
// getTrafficPolicy 获取隧道的流量策略
|
||
func (s *Server) getTrafficPolicy(c *gin.Context) {
|
||
id, _ := strconv.Atoi(c.Param("id"))
|
||
policy := trafficmgr.GetPolicy(uint(id))
|
||
c.JSON(200, policy)
|
||
}
|
||
|
||
// saveTrafficPolicy 设置流量配额/限速策略
|
||
func (s *Server) saveTrafficPolicy(c *gin.Context) {
|
||
id, _ := strconv.Atoi(c.Param("id"))
|
||
var req model.TrafficPolicy
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
req.TunnelID = uint(id)
|
||
if err := trafficmgr.SavePolicy(&req); err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
store.Audit(c.GetString("username"), "set_policy", fmt.Sprintf("tunnel#%d", id),
|
||
fmt.Sprintf("配额=%d 限速=%d 动作=%s", req.QuotaBytes, req.RateLimitMbps, req.ActionOnExceed))
|
||
c.JSON(200, gin.H{"ok": true})
|
||
}
|
||
|
||
// ---- 专线管理 ----
|
||
|
||
func (s *Server) listTunnels(c *gin.Context) {
|
||
var tunnels []model.Tunnel
|
||
q := store.DB.Preload("Node").Order("id desc")
|
||
if p := c.Query("protocol"); p != "" {
|
||
q = q.Where("protocol = ?", p)
|
||
}
|
||
if kw := c.Query("keyword"); kw != "" {
|
||
q = q.Where("name LIKE ?", "%"+kw+"%")
|
||
}
|
||
q.Find(&tunnels)
|
||
// 附带实时状态
|
||
type resp struct {
|
||
model.Tunnel
|
||
Runtime *core.Status `json:"runtime,omitempty"`
|
||
}
|
||
out := make([]resp, 0, len(tunnels))
|
||
for _, t := range tunnels {
|
||
r := resp{Tunnel: t}
|
||
if st, ok := s.mgr.GetStatus(t.ID); ok {
|
||
r.Runtime = &st
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
c.JSON(200, out)
|
||
}
|
||
|
||
type tunnelReq struct {
|
||
Name string `json:"name"`
|
||
Protocol string `json:"protocol"`
|
||
Remark string `json:"remark"`
|
||
Config map[string]interface{} `json:"config"`
|
||
AutoStart bool `json:"auto_start"`
|
||
ExpireAt *time.Time `json:"expire_at"`
|
||
NodeID *uint `json:"node_id"`
|
||
}
|
||
|
||
func (s *Server) createTunnel(c *gin.Context) {
|
||
var req tunnelReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
if req.Name == "" || req.Protocol == "" {
|
||
c.JSON(400, gin.H{"error": "名称与协议类型必填"})
|
||
return
|
||
}
|
||
p, err := s.mgr.Get(req.Protocol)
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
cfg, err := p.Validate(req.Config)
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
t := model.Tunnel{
|
||
Name: req.Name, Protocol: req.Protocol, Remark: req.Remark,
|
||
State: "stopped", AutoStart: req.AutoStart, ExpireAt: req.ExpireAt, NodeID: req.NodeID,
|
||
}
|
||
// 先创建不含配置的记录以取得 ID;敏感字段只在内存中交给协议层,
|
||
// 由 SaveTunnelConfig 拆分并加密后才会写入数据库。
|
||
t.Config = "{}"
|
||
if err := store.DB.Create(&t).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败: " + err.Error()})
|
||
return
|
||
}
|
||
raw, _ := json.Marshal(cfg)
|
||
t.Config = string(raw)
|
||
if err := p.Create(&t); err != nil {
|
||
store.DB.Delete(&t)
|
||
_ = service.RemoveInstance(&t)
|
||
c.JSON(500, gin.H{"error": "生成配置失败: " + err.Error()})
|
||
return
|
||
}
|
||
username := c.GetString("username")
|
||
store.Audit(username, "create_tunnel", fmt.Sprintf("%s(%s)", t.Name, t.Protocol), "")
|
||
if t.AutoStart {
|
||
if err := p.Start(&t); err != nil {
|
||
t.LastError = err.Error()
|
||
t.State = "error"
|
||
} else {
|
||
t.State = "running"
|
||
}
|
||
store.DB.Save(&t)
|
||
}
|
||
c.JSON(200, t)
|
||
}
|
||
|
||
func (s *Server) getTunnel(c *gin.Context) {
|
||
t, err := s.mustTunnel(c)
|
||
if err != nil {
|
||
return
|
||
}
|
||
// 客户端配置预览
|
||
var clientConf 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
|
||
}
|