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

1717 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api 提供 REST API 路由与处理器
package api
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"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
}
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.POST("/system/fix", s.systemFix)
admin.GET("/system/tls", s.tlsStatus)
admin.GET("/system/identity", idp.Status)
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.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)
}
c.JSON(200, t)
}
func (s *Server) getTunnel(c *gin.Context) {
t, err := s.mustTunnel(c)
if err != nil {
return
}
// 客户端配置预览
var clientConf, clientConfError string
if p, err := s.mgr.Get(t.Protocol); err == nil {
clientConf, err = p.GenerateClientConfig(t)
if err != nil {
clientConfError = err.Error()
}
}
c.JSON(200, gin.H{"tunnel": t, "client_config": clientConf, "client_config_error": clientConfError})
}
func (s *Server) updateTunnel(c *gin.Context) {
t, err := s.mustTunnel(c)
if err != nil {
return
}
mu := lockTunnel(t.ID)
defer mu.Unlock()
var req tunnelReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
hasCfg := req.Config != nil
if !hasCfg {
if req.Name != "" {
t.Name = req.Name
}
t.Remark, t.AutoStart, t.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt
if err := store.DB.Model(t).Updates(map[string]interface{}{
"name": t.Name, "remark": t.Remark, "auto_start": t.AutoStart, "expire_at": t.ExpireAt,
}).Error; err != nil {
c.JSON(500, gin.H{"error": "保存失败: " + err.Error()})
return
}
store.Audit(c.GetString("username"), "update_tunnel", t.Name, "")
c.JSON(200, t)
return
}
p, err := s.mgr.Get(t.Protocol)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
cfg, err := p.Validate(req.Config)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
raw, _ := json.Marshal(cfg)
original := *t
candidate := original
candidate.Config = string(raw)
if req.Name != "" {
candidate.Name = req.Name
}
candidate.Remark, candidate.AutoStart, candidate.ExpireAt = req.Remark, req.AutoStart, req.ExpireAt
wasRunning := original.State == "running"
backup, err := service.SnapshotInstance(&original)
if err != nil {
c.JSON(500, gin.H{"error": "无法备份旧配置,已取消更新: " + err.Error()})
return
}
defer service.CleanupInstanceSnapshot(backup)
rollback := func(cause error) error {
_ = service.RestoreInstanceSnapshot(&original, backup)
if err := store.DB.Save(&original).Error; err != nil {
return fmt.Errorf("恢复数据库配置失败: %w", err)
}
if wasRunning {
if err := p.Start(&original); err != nil {
return fmt.Errorf("恢复旧服务失败: %w", err)
}
}
*t = original
return cause
}
if wasRunning {
if err := p.Stop(&original); err != nil {
c.JSON(500, gin.H{"error": "停止旧配置失败: " + err.Error()})
return
}
}
if err := p.Create(&candidate); err != nil {
if restoreErr := rollback(err); restoreErr != err {
c.JSON(500, gin.H{"error": "生成新配置失败,且回滚异常: " + restoreErr.Error()})
return
}
c.JSON(500, gin.H{"error": "生成新配置失败,已恢复旧配置: " + err.Error()})
return
}
if wasRunning {
if err := p.Start(&candidate); err != nil {
if restoreErr := rollback(err); restoreErr != err {
c.JSON(500, gin.H{"error": "启动新配置失败,且回滚异常: " + restoreErr.Error()})
return
}
c.JSON(500, gin.H{"error": "启动新配置失败,已恢复旧服务: " + err.Error()})
return
}
candidate.State, candidate.LastError = "running", ""
if err := store.DB.Model(&candidate).Updates(map[string]interface{}{"state": candidate.State, "last_error": ""}).Error; err != nil {
_ = rollback(err)
c.JSON(500, gin.H{"error": "保存运行状态失败,已恢复旧服务: " + err.Error()})
return
}
}
*t = candidate
store.Audit(c.GetString("username"), "update_tunnel", t.Name, "")
c.JSON(200, t)
}
func (s *Server) deleteTunnel(c *gin.Context) {
t, err := s.mustTunnel(c)
if err != nil {
return
}
mu := lockTunnel(t.ID)
defer mu.Unlock()
if p, err := s.mgr.Get(t.Protocol); err == nil {
_ = p.Delete(t)
}
if err := service.RemoveInstance(t); err != nil && !os.IsNotExist(err) {
c.JSON(500, gin.H{"error": "清理实例文件失败: " + err.Error()})
return
}
s.mgr.RemoveStatus(t.ID)
store.DB.Delete(t)
store.Audit(c.GetString("username"), "delete_tunnel", t.Name, "")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) startTunnel(c *gin.Context) {
t, err := s.mustTunnel(c)
if err != nil {
return
}
mu := lockTunnel(t.ID)
defer mu.Unlock()
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) {
c.JSON(400, gin.H{"error": "专线已到期,请先调整到期时间"})
return
}
p, err := s.mgr.Get(t.Protocol)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if t.State == "running" {
c.JSON(200, gin.H{"ok": true, "state": "running", "message": "专线已在运行"})
return
}
if err := p.Start(t); err != nil {
t.State = "error"
t.LastError = err.Error()
store.DB.Save(t)
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, "")
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, "")
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
}