feat: add openconnect

This commit is contained in:
duorameng
2026-06-25 20:06:38 +08:00
parent fac1582fe0
commit 90f06de298
49 changed files with 3854 additions and 969 deletions
+14
View File
@@ -10,9 +10,11 @@ import (
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/executor"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/router"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/tunnel"
"github.com/engigu/baihu-panel/internal/utils"
"github.com/gin-gonic/gin"
@@ -26,8 +28,19 @@ type App struct {
func New() *App {
app := InitBasic()
app.initRouter()
// 初始化完成后将路由引擎注入到隧道模块,以支持高性能的纯内存代理
tunnel.SetLocalEngine(app.Router)
// 初始化隧道后台服务 (读取配置决定角色并启动服务)
tunnel.Init()
// 启动系统级后台定时任务调度器
executor.InitSysCron()
// 初始化完成后回收一次内存
utils.FreeMemory()
return app
}
@@ -45,6 +58,7 @@ func InitBasic() *App {
// 自动加载配置 (内部会自动处理 BH_CONFIG_PATH 环境变量与默认路径的优先级)
app.initConfigWithPath("")
app.initDatabase()
logger.Infof("[System] 低于1.0.11版本升级最新版本错误指引: https://github.com/engigu/baihu-panel/issues/64")
globalApp = app
})
+9 -9
View File
@@ -14,20 +14,20 @@ const (
DefaultTaskTimeout = 30
// Settings Section 常量
SectionSite = "site"
SectionSystem = "system"
SectionScheduler = "scheduler"
SectionSecurity = "security"
SectionNotify = "notify"
SectionSite = "site"
SectionSystem = "system"
SectionScheduler = "scheduler"
SectionSecurity = "security"
SectionNotify = "notify"
// Site Settings Key 常量
KeyTitle = "title"
KeySubtitle = "subtitle"
KeyIcon = "icon"
KeyPageSize = "page_size"
KeyCookieDays = "cookie_days"
KeyOpenapiToken = "openapi_token"
KeyActiveWebUI = "active_webui"
KeyPageSize = "page_size"
KeyCookieDays = "cookie_days"
KeyOpenapiToken = "openapi_token"
KeyActiveWebUI = "active_webui"
// Security Settings Key 常量
KeySecret = "secret"
+22
View File
@@ -0,0 +1,22 @@
package constant
const (
// CookieActiveInterconnectNodeID 穿越状态下标识目标子节点 ID 的 Cookie 键名
CookieActiveInterconnectNodeID = "active_interconnect_node_id"
// SectionInterconnect 互联设置分组
SectionInterconnect = "interconnect"
// 互联设置相关 Key
KeyInterconnectToken = "interconnect_token"
KeyInterconnectParentURL = "interconnect_parent_url"
KeyInterconnectParentToken = "interconnect_parent_token"
KeyInterconnectRole = "interconnect_role"
// 互联角色
InterconnectRoleMaster = "master"
InterconnectRoleChild = "child"
// 互联系统事件
EventInterconnectChildStatus = "interconnect_child_status"
)
@@ -0,0 +1,526 @@
package controllers
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/models/vo"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/tunnel"
"github.com/engigu/baihu-panel/internal/utils"
"github.com/gin-gonic/gin"
)
type InterconnectController struct {
interconnectService *services.InterconnectService
httpClient *http.Client
}
func NewInterconnectController(interconnectService *services.InterconnectService) *InterconnectController {
return &InterconnectController{
interconnectService: interconnectService,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// GetNodes 获取互联节点列表
func (ic *InterconnectController) GetNodes(c *gin.Context) {
nodes, err := ic.interconnectService.GetNodes()
if err != nil {
utils.ServerError(c, "获取互联节点失败")
return
}
utils.Success(c, nodes)
}
// CreateNode 创建互联节点
func (ic *InterconnectController) CreateNode(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
URL string `json:"url"`
Token string `json:"token" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
node, err := ic.interconnectService.CreateNode(req.Name, req.URL, req.Token, req.Remark)
if err != nil {
utils.ServerError(c, "创建互联节点失败")
return
}
utils.Success(c, node)
}
// UpdateNode 更新互联节点
func (ic *InterconnectController) UpdateNode(c *gin.Context) {
id := c.Param("id")
if id == "" {
utils.BadRequest(c, "无效的节点ID")
return
}
var req struct {
Name string `json:"name" binding:"required"`
URL string `json:"url"`
Token string `json:"token" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
node, err := ic.interconnectService.UpdateNode(id, req.Name, req.URL, req.Token, req.Remark)
if err != nil {
utils.ServerError(c, "更新互联节点失败")
return
}
utils.Success(c, node)
}
// DeleteNode 删除互联节点
func (ic *InterconnectController) DeleteNode(c *gin.Context) {
id := c.Param("id")
if id == "" {
utils.BadRequest(c, "无效的节点ID")
return
}
err := ic.interconnectService.DeleteNode(id)
if err != nil {
utils.ServerError(c, "删除互联节点失败")
return
}
utils.Success(c, nil)
}
// GetNodeStatus 获取单个子节点的状态
func (ic *InterconnectController) GetNodeStatus(c *gin.Context) {
id := c.Param("id")
node, err := ic.interconnectService.GetNodeByID(id)
if err != nil {
utils.NotFound(c, "节点不存在")
return
}
// 针对反向隧道节点状态检测的特判
if strings.HasPrefix(node.URL, "tunnel://") {
sess := tunnel.GetSession(node.ID)
if sess == nil {
c.JSON(200, gin.H{"code": 500, "msg": "节点离线或反向隧道未建立", "data": nil})
return
}
// 使用当前 Yamux Session 的虚拟底层连接进行拨号
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return sess.Session.Open()
},
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
req, err := http.NewRequest("GET", "http://tunnel.local/api/v1/monitor", nil)
if err != nil {
utils.ServerError(c, "构建检测请求失败")
return
}
req.Header.Set("Authorization", "Bearer "+node.Token)
resp, err := client.Do(req)
if err != nil {
c.JSON(200, gin.H{"code": 500, "msg": "与子节点逆向连接通讯失败", "data": nil})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
c.JSON(200, gin.H{"code": 500, "msg": "子节点检测异常", "data": string(body)})
return
}
var jsonResp map[string]interface{}
if err := json.Unmarshal(body, &jsonResp); err != nil {
utils.ServerError(c, "解析节点检测数据失败")
return
}
if dataMap, ok := jsonResp["data"].(map[string]interface{}); ok {
dataMap["tunnel_connected"] = true
dataMap["tunnel_url"] = node.URL
if hostMap, ok := dataMap["host"].(map[string]interface{}); ok {
hostMap["tx_bytes"] = node.Metrics.TxBytes
hostMap["rx_bytes"] = node.Metrics.RxBytes
}
}
utils.Success(c, jsonResp["data"])
return
}
apiURL := strings.TrimRight(node.URL, "/") + "/api/v1/monitor"
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
utils.ServerError(c, "构建请求失败")
return
}
req.Header.Set("Authorization", "Bearer "+node.Token)
resp, err := ic.httpClient.Do(req)
if err != nil {
c.JSON(200, gin.H{"code": 500, "msg": "节点离线或网络不可达", "data": nil})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
c.JSON(200, gin.H{"code": 500, "msg": "节点返回异常", "data": string(body)})
return
}
var jsonResp map[string]interface{}
if err := json.Unmarshal(body, &jsonResp); err != nil {
utils.ServerError(c, "解析节点响应失败")
return
}
utils.Success(c, jsonResp["data"])
}
// SyncScript 将脚本同步到指定的节点列表
func (ic *InterconnectController) SyncScript(c *gin.Context) {
var req struct {
NodeIDs []string `json:"node_ids" binding:"required"`
Filename string `json:"filename" binding:"required"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
results := make([]map[string]interface{}, 0)
for _, nodeID := range req.NodeIDs {
node, err := ic.interconnectService.GetNodeByID(nodeID)
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
continue
}
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/scripts/save")
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
continue
}
payload := map[string]interface{}{
"filename": req.Filename,
"content": req.Content,
}
payloadBytes, _ := json.Marshal(payload)
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
continue
}
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil || resp.StatusCode != 200 {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
} else {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
}
if resp != nil {
resp.Body.Close()
}
}
utils.Success(c, results)
}
// SyncEnv 将环境变量同步到指定的节点列表
func (ic *InterconnectController) SyncEnv(c *gin.Context) {
var req struct {
NodeIDs []string `json:"node_ids" binding:"required"`
Envs []struct {
Name string `json:"name"`
Value string `json:"value"`
Remark string `json:"remark"`
} `json:"envs" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
results := make([]map[string]interface{}, 0)
for _, nodeID := range req.NodeIDs {
node, err := ic.interconnectService.GetNodeByID(nodeID)
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
continue
}
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/env")
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
continue
}
successCount := 0
for _, env := range req.Envs {
payload := map[string]interface{}{
"name": env.Name,
"value": env.Value,
"remark": env.Remark,
"type": "normal",
}
payloadBytes, _ := json.Marshal(payload)
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
continue
}
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err == nil && resp.StatusCode == 200 {
successCount++
}
if resp != nil {
resp.Body.Close()
}
}
results = append(results, map[string]interface{}{
"node_id": nodeID,
"success": true,
"msg": "同步完成",
"count": successCount,
})
}
utils.Success(c, results)
}
// SyncTask 将任务同步到指定的节点列表
func (ic *InterconnectController) SyncTask(c *gin.Context) {
var req struct {
NodeIDs []string `json:"node_ids" binding:"required"`
Tasks []vo.TaskVO `json:"tasks" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
results := make([]map[string]interface{}, 0)
for _, nodeID := range req.NodeIDs {
node, err := ic.interconnectService.GetNodeByID(nodeID)
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
continue
}
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/tasks/bulk_save")
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
continue
}
payloadBytes, _ := json.Marshal(req.Tasks)
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
continue
}
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil || resp.StatusCode != 200 {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
} else {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
}
if resp != nil {
resp.Body.Close()
}
}
utils.Success(c, results)
}
// HandleTunnel 接受子节点 WebSocket 连接请求
func (ic *InterconnectController) HandleTunnel(c *gin.Context) {
tunnel.HandleTunnel(c)
}
// ProxyRequest 代理转发请求至目标节点
func (ic *InterconnectController) ProxyRequest(c *gin.Context) {
nodeID := c.Param("node_id")
path := c.Param("path")
if nodeID == "" {
utils.BadRequest(c, "Node ID required")
return
}
node, err := ic.interconnectService.GetNodeByID(nodeID)
if err != nil {
utils.NotFound(c, "Node not found")
return
}
if strings.HasPrefix(node.URL, "tunnel://") {
// 走 WebSocket 逆向隧道 (基于 Yamux 流式多路复用)
err := tunnel.ProxyHTTP(nodeID, c, path)
if err != nil {
utils.ServerError(c, "Tunnel request failed: "+err.Error())
}
return
}
// 走普通 HTTP 直连
// Construct the target URL
targetURL := strings.TrimRight(node.URL, "/") + path
if c.Request.URL.RawQuery != "" {
targetURL += "?" + c.Request.URL.RawQuery
}
req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body)
if err != nil {
utils.ServerError(c, "Failed to create proxy request")
return
}
// Copy headers
req.Header = c.Request.Header.Clone()
// If the node token exists, append it as Bearer Auth
if node.Token != "" {
req.Header.Set("Authorization", "Bearer "+node.Token)
}
resp, err := ic.httpClient.Do(req)
if err != nil {
utils.ServerError(c, "Failed to connect to target node: "+err.Error())
return
}
defer resp.Body.Close()
for k, v := range resp.Header {
for _, vv := range v {
c.Writer.Header().Add(k, vv)
}
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
// ReportMonitorData 接收子节点上报的监控数据
func (ic *InterconnectController) ReportMonitorData(c *gin.Context) {
var req models.NodeMetrics
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(401, gin.H{"error": "missing authorization"})
return
}
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
node, err := ic.interconnectService.GetNodeByToken(tokenStr)
if err != nil {
c.JSON(401, gin.H{"error": "invalid token"})
return
}
err = ic.interconnectService.UpdateNodeMonitorData(node.ID, req)
if err != nil {
utils.ServerError(c, "更新节点数据失败")
return
}
utils.Success(c, gin.H{
"tunnel_url": node.URL,
})
}
// GetChildStatus 获取本机作为子节点的连接状态
func (ic *InterconnectController) GetChildStatus(c *gin.Context) {
settingsSvc := services.NewSettingsService()
parentURL := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentURL)
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
connected := tunnel.IsTunnelConnected()
tunnelURL := tunnel.GetLocalTunnelURL()
utils.Success(c, gin.H{
"parent_url": parentURL,
"parent_token": parentToken,
"connected": connected,
"tunnel_url": tunnelURL,
"tx_bytes": tunnel.GetTxBytes(),
"rx_bytes": tunnel.GetRxBytes(),
})
}
// getClientAndURL 辅助方法:根据节点类型决定走直连还是隧道,并返回对应的 Client 和完整 URL
func (ic *InterconnectController) getClientAndURL(node *models.InterconnectNode, path string) (*http.Client, string, error) {
if strings.HasPrefix(node.URL, "tunnel://") {
sess := tunnel.GetSession(node.ID)
if sess == nil {
return nil, "", net.ErrClosed
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return sess.Session.Open()
},
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
return client, "http://tunnel.local" + path, nil
}
targetURL := strings.TrimRight(node.URL, "/") + path
return ic.httpClient, targetURL, nil
}
+31 -111
View File
@@ -1,34 +1,20 @@
package controllers
import (
"math/rand"
"net/http"
"runtime"
"sync"
"time"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/services/tasks"
"github.com/engigu/baihu-panel/internal/utils"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
)
type MonitorController struct {
executorService *tasks.ExecutorService
// 缓存物理机状态
hostMu sync.RWMutex
lastUpdate time.Time
cpuPercent float64
vMem *mem.VirtualMemoryStat
diskUsage *disk.UsageStat
hostInfo *host.InfoStat
}
func NewMonitorController(executorService *tasks.ExecutorService) *MonitorController {
@@ -49,118 +35,52 @@ func (mc *MonitorController) GetSystemMonitor(c *gin.Context) {
utils.Success(c, data)
}
// MonitorWS WebSocket 获取系统监控数据
func (mc *MonitorController) MonitorWS(c *gin.Context) {
ws, err := monitorUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer ws.Close()
// MonitorSSE Server-Sent Events 获取系统监控数据
func (mc *MonitorController) MonitorSSE(c *gin.Context) {
// 设置 SSE 响应头
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("Transfer-Encoding", "chunked")
// 初始发送一次数据
if err := mc.sendMonitorData(ws); err != nil {
if err := mc.sendMonitorDataSSE(c); err != nil {
return
}
ticker := time.NewTicker(3 * time.Second)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := mc.sendMonitorData(ws); err != nil {
if err := mc.sendMonitorDataSSE(c); err != nil {
return // 客户端断开连接或发送失败
}
case <-c.Request.Context().Done():
return
return // 连接已断开,立即退出
}
}
}
func (mc *MonitorController) sendMonitorData(ws *websocket.Conn) error {
func (mc *MonitorController) sendMonitorDataSSE(c *gin.Context) error {
data := mc.getMonitorData()
return ws.WriteJSON(gin.H{
// 使用 Gin 提供的 SSE 方法
c.SSEvent("message", gin.H{
"code": 200,
"data": data,
"msg": "success",
})
}
func (mc *MonitorController) updateHostMetrics() {
mc.hostMu.Lock()
defer mc.hostMu.Unlock()
// 缓存 2 秒
if time.Since(mc.lastUpdate) < 2*time.Second && mc.vMem != nil {
return
}
if constant.DemoMode {
mc.updateDemoMetrics()
return
}
cpuPercents, _ := cpu.Percent(0, false)
if len(cpuPercents) > 0 {
mc.cpuPercent = cpuPercents[0]
}
mc.vMem, _ = mem.VirtualMemory()
mc.diskUsage, _ = disk.Usage("/")
mc.hostInfo, _ = host.Info()
mc.lastUpdate = time.Now()
}
func (mc *MonitorController) updateDemoMetrics() {
mc.cpuPercent = 10 + rand.Float64()*40 // 10% - 50% 的随机 CPU 波动
totalMem := uint64(8 * 1024 * 1024 * 1024) // 8GB 内存
usedMem := uint64(float64(totalMem) * (0.3 + rand.Float64()*0.3)) // 30% - 60% 随机使用率
mc.vMem = &mem.VirtualMemoryStat{
Total: totalMem,
Used: usedMem,
UsedPercent: float64(usedMem) / float64(totalMem) * 100,
}
totalDisk := uint64(500 * 1024 * 1024 * 1024) // 500GB 硬盘
usedDisk := uint64(float64(totalDisk) * 0.45) // 固定 45% 使用率
mc.diskUsage = &disk.UsageStat{
Total: totalDisk,
Used: usedDisk,
UsedPercent: float64(usedDisk) / float64(totalDisk) * 100,
}
mc.hostInfo = &host.InfoStat{
Platform: "Demo Environment",
OS: "linux",
Uptime: uint64(time.Now().Unix() - 1700000000), // 生成一个较长且持续增加的运行时间
}
mc.lastUpdate = time.Now()
c.Writer.Flush()
return nil
}
func (mc *MonitorController) getMonitorData() gin.H {
var m runtime.MemStats
runtime.ReadMemStats(&m)
rt := services.GetMonitorService().GetRuntimeMetrics()
m := rt.MemStats
// 更新并读取缓存的物理机指标
mc.updateHostMetrics()
mc.hostMu.RLock()
cpuPercent := mc.cpuPercent
vMem := mc.vMem
diskUsage := mc.diskUsage
hostInfo := mc.hostInfo
mc.hostMu.RUnlock()
// 提供默认值防空指针
if vMem == nil {
vMem = &mem.VirtualMemoryStat{}
}
if diskUsage == nil {
diskUsage = &disk.UsageStat{}
}
if hostInfo == nil {
hostInfo = &host.InfoStat{}
}
// 调用统一的监控服务获取物理机指标
metrics := services.GetMonitorService().GetHostMetrics()
return gin.H{
"env": gin.H{
@@ -168,18 +88,18 @@ func (mc *MonitorController) getMonitorData() gin.H {
"arch": runtime.GOARCH,
"go_version": runtime.Version(),
"num_cpu": runtime.NumCPU(),
"goroutines": runtime.NumGoroutine(),
"goroutines": rt.NumGoroutine,
},
"host": gin.H{
"cpu_percent": cpuPercent,
"mem_total": vMem.Total,
"mem_used": vMem.Used,
"mem_percent": vMem.UsedPercent,
"disk_total": diskUsage.Total,
"disk_used": diskUsage.Used,
"disk_percent": diskUsage.UsedPercent,
"uptime": hostInfo.Uptime,
"platform": hostInfo.Platform + " " + hostInfo.PlatformVersion,
"cpu_percent": metrics.CPUPercent,
"mem_total": metrics.VMem.Total,
"mem_used": metrics.VMem.Used,
"mem_percent": metrics.VMem.UsedPercent,
"disk_total": metrics.DiskUsage.Total,
"disk_used": metrics.DiskUsage.Used,
"disk_percent": metrics.DiskUsage.UsedPercent,
"uptime": metrics.HostInfo.Uptime,
"platform": metrics.HostInfo.Platform + " " + metrics.HostInfo.PlatformVersion,
},
"mem": gin.H{
"alloc": m.Alloc,
+39 -3
View File
@@ -19,6 +19,7 @@ import (
"github.com/engigu/baihu-panel/internal/models/vo"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/services/tasks"
"github.com/engigu/baihu-panel/internal/tunnel"
"github.com/engigu/baihu-panel/internal/utils"
"github.com/gin-gonic/gin"
@@ -134,6 +135,20 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) {
func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
settings := sc.settingsService.GetSection(constant.SectionSite)
// 纠正数据库中的空值,防止因配置冲突被意外置空
if settings[constant.KeyTitle] == "" {
settings[constant.KeyTitle] = "白虎面板"
sc.settingsService.Set(constant.SectionSite, constant.KeyTitle, "白虎面板")
}
if settings[constant.KeySubtitle] == "" {
settings[constant.KeySubtitle] = "极致轻量、高性能的自动化任务调度平台"
sc.settingsService.Set(constant.SectionSite, constant.KeySubtitle, "极致轻量、高性能的自动化任务调度平台")
}
if settings[constant.KeyIcon] == "" {
settings[constant.KeyIcon] = constant.DefaultIcon
sc.settingsService.Set(constant.SectionSite, constant.KeyIcon, constant.DefaultIcon)
}
// 解析 JSON 格式的 OpenAPI Token
if tokenJson, ok := settings[constant.KeyOpenapiToken]; ok && tokenJson != "" {
var tokenConfig vo.TokenConfig
@@ -164,11 +179,25 @@ func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
// GetPublicSiteSettings 获取公开的站点设置(无需认证)
func (sc *SettingsController) GetPublicSiteSettings(c *gin.Context) {
settings := sc.settingsService.GetSection(constant.SectionSite)
title := settings[constant.KeyTitle]
if title == "" {
title = "白虎面板"
}
subtitle := settings[constant.KeySubtitle]
if subtitle == "" {
subtitle = "极致轻量、高性能的自动化任务调度平台"
}
icon := settings[constant.KeyIcon]
if icon == "" {
icon = constant.DefaultIcon
}
// 只返回公开信息
utils.Success(c, gin.H{
constant.KeyTitle: settings[constant.KeyTitle],
constant.KeySubtitle: settings[constant.KeySubtitle],
constant.KeyIcon: settings[constant.KeyIcon],
constant.KeyTitle: title,
constant.KeySubtitle: subtitle,
constant.KeyIcon: icon,
"demo_mode": constant.DemoMode,
})
}
@@ -530,6 +559,13 @@ func (sc *SettingsController) UpdateSectionSettings(c *gin.Context) {
return
}
// 当互联配置发生改变时,通知 tunnel 模块立刻应用新角色,启动或停止相关的后台协程
if section == constant.SectionInterconnect {
if role, ok := values[constant.KeyInterconnectRole]; ok {
tunnel.ApplyRole(role)
}
}
utils.SuccessMsg(c, "保存成功")
}
+82
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/models/vo"
@@ -227,6 +228,87 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
utils.Success(c, vo.ToTaskVO(task))
}
// BulkSaveTask 批量保存/导入任务配置(用于主节点下发同步)
// @Summary 批量保存任务
// @Description 批量导入任务配置,如果ID或同名存在则更新,不存在则创建
// @Tags 任务管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Router /tasks/bulk_save [post]
func (tc *TaskController) BulkSaveTask(c *gin.Context) {
var reqs []vo.TaskVO
if err := c.ShouldBindJSON(&reqs); err != nil {
utils.BadRequest(c, err.Error())
return
}
for _, req := range reqs {
param := tasks.TaskParam{
Name: req.Name,
Remark: req.Remark,
Command: req.Command,
PreCommand: req.PreCommand,
PostCommand: req.PostCommand,
Tags: req.Tags,
Type: req.Type,
Config: req.Config,
Schedule: req.Schedule,
Timeout: req.Timeout,
WorkDir: req.WorkDir,
CleanConfig: req.CleanConfig,
Envs: req.Envs,
Languages: req.Languages,
AgentID: req.AgentID,
TriggerType: req.TriggerType,
RetryCount: req.RetryCount,
RetryInterval: req.RetryInterval,
RandomRange: req.RandomRange,
PinType: req.PinType,
Enabled: req.Enabled,
SourceID: "", // 不直接覆盖
}
var existingTask *models.Task
// 优先按 ID 匹配
if req.ID != "" {
existingTask = tc.taskService.GetTaskByID(req.ID)
}
// 如果 ID 没找到,尝试按 Name 匹配
if existingTask == nil {
var t models.Task
res := database.DB.Where("name = ?", req.Name).First(&t)
if res.Error == nil {
existingTask = &t
}
}
var savedTask *models.Task
if existingTask != nil {
savedTask = tc.taskService.UpdateTask(existingTask.ID, &param)
} else {
savedTask = tc.taskService.CreateTask(&param)
// 如果原始有 ID,强制覆盖更新 ID 保持强同步一致性
if req.ID != "" && savedTask != nil {
database.DB.Model(savedTask).Update("id", req.ID)
savedTask.ID = req.ID
}
}
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
if savedTask != nil {
if savedTask.AgentID != nil && *savedTask.AgentID != "" {
tc.agentWSManager.BroadcastTasks(*savedTask.AgentID)
} else {
tc.executorService.AddCronTask(savedTask)
}
}
}
utils.Success(c, nil)
}
// GetTasks 获取任务列表
// @Summary 获取任务列表
// @Description 分页获取任务列表,支持按名称、Agent ID、标签、类型筛选
+12 -1
View File
@@ -224,6 +224,7 @@ func (tc *TerminalController) handlePtyMode(conn *websocket.Conn, userID string)
close(pingDone)
cmd.Process.Kill()
cmd.Wait()
ptmx.Close() // Force close PTY to interrupt the blocking ptmx.Read() in the goroutine
wg.Wait()
}
@@ -369,9 +370,19 @@ func (tc *TerminalController) handlePipeMode(conn *websocket.Conn, userID string
}
close(pingDone)
stdin.Close()
cmd.Process.Kill()
cmd.Wait()
if stdinCloser, ok := stdin.(io.Closer); ok {
stdinCloser.Close()
}
if stdoutCloser, ok := stdout.(io.Closer); ok {
stdoutCloser.Close()
}
if stderrCloser, ok := stderr.(io.Closer); ok {
stderrCloser.Close()
}
wg.Wait()
}
+1
View File
@@ -29,6 +29,7 @@ var allModels = []interface{}{
&models.NotifyBinding{},
&models.DataRelation{},
&models.DataStorage{},
&models.InterconnectNode{},
}
func Migrate() error {
+69
View File
@@ -0,0 +1,69 @@
package executor
import (
"sync"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/systime"
"github.com/robfig/cron/v3"
)
type SysCronManager struct {
cron *cron.Cron
}
var (
sysCronInstance *SysCronManager
sysCronOnce sync.Once
)
// InitSysCron 初始化系统的内部定时器
func InitSysCron(){
GetSysCron()
}
// GetSysCron 获取内部系统定时器服务单例
func GetSysCron() *SysCronManager {
sysCronOnce.Do(func() {
// 使用秒级精度,指定为东八区
c := cron.New(cron.WithSeconds(), cron.WithLocation(systime.CST))
c.Start()
sysCronInstance = &SysCronManager{
cron: c,
}
logger.Infof("[SysCron] 内部系统定时管理器已启动")
})
return sysCronInstance
}
// AddJob 添加内部系统任务,spec为cron表达式(支持 @every 30s 这种快捷方式)
func (s *SysCronManager) AddJob(spec string, cmd func()) (cron.EntryID, error) {
id, err := s.cron.AddFunc(spec, cmd)
if err != nil {
logger.Errorf("[SysCron] 无法添加系统任务: %s, err: %v", spec, err)
return 0, err
}
return id, nil
}
// AddJobWithRun 立即开启一个协程异步执行一次任务,随后将其加入到系统定时任务中
func (s *SysCronManager) AddJobWithRun(spec string, cmd func()) (cron.EntryID, error) {
// 立即异步执行一次
go func() {
defer func() {
if r := recover(); r != nil {
logger.Errorf("[SysCron] 立即执行任务时发生 panic: %v", r)
}
}()
cmd()
}()
// 然后加入定时器
return s.AddJob(spec, cmd)
}
// RemoveJob 动态移除指定的系统定时任务
func (s *SysCronManager) RemoveJob(id cron.EntryID) {
s.cron.Remove(id)
}
+42
View File
@@ -5,6 +5,7 @@ import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/engigu/baihu-panel/internal/constant"
@@ -34,6 +35,47 @@ func AuthRequired() gin.HandlerFunc {
}
}
// 检查是否携带互联 Token(支持跨面板远程全接口调用)
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if tokenStr != "" {
settingsSvc := services.NewSettingsService()
interconnectToken := settingsSvc.Get(constant.SectionSite, constant.KeyInterconnectToken)
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
isMatched := false
h1 := sha256.Sum256([]byte(tokenStr))
if interconnectToken != "" {
h2 := sha256.Sum256([]byte(interconnectToken))
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
isMatched = true
}
}
if !isMatched && parentToken != "" {
h2 := sha256.Sum256([]byte(parentToken))
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
isMatched = true
}
}
if isMatched {
// 模拟 Admin 角色
var adminUser models.User
res := database.DB.Where("role = ?", constant.AdminRole).Limit(1).Find(&adminUser)
if res.Error == nil && res.RowsAffected > 0 {
c.Set("userID", adminUser.ID)
c.Set("username", adminUser.Username)
c.Set("role", adminUser.Role)
c.Next()
return
}
}
}
}
token, err := c.Cookie(constant.CookieName)
if err != nil || token == "" {
utils.Unauthorized(c, "请先登录")
+133
View File
@@ -0,0 +1,133 @@
package middleware
import (
"context"
"io"
"net/http"
"strings"
"time"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/tunnel"
"github.com/gin-gonic/gin"
)
var travelProxyClient = &http.Client{
Timeout: 10 * time.Second,
}
func TravelProxyMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 检查是否存在 active_interconnect_node_id Cookie
nodeID, err := c.Cookie(constant.CookieActiveInterconnectNodeID)
if err != nil || nodeID == "" {
c.Next()
return
}
// 2. 白名单放行:反向隧道建立连接端点必须直达本机,不能二次代理
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/v1/interconnect/tunnel") {
c.Next()
return
}
// 3. 查询数据库中节点信息
var node models.InterconnectNode
if err := database.DB.Where("id = ?", nodeID).First(&node).Error; err != nil {
// 节点不存在,说明 Cookie 无效,清除并放行
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
c.Next()
return
}
// 4. 准备代理的路径(若主节点配置了 URLPrefix,需要剥离)
cfg := services.GetConfig()
urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/")
targetPath := path
if urlPrefix != "" {
targetPath = strings.TrimPrefix(targetPath, urlPrefix)
}
if !strings.HasPrefix(targetPath, "/") {
targetPath = "/" + targetPath
}
// 5. 执行代理转发
if strings.HasPrefix(node.URL, "tunnel://") {
// 走 WebSocket 逆向 Yamux 隧道
err := tunnel.ProxyHTTP(nodeID, c, targetPath)
if err != nil {
// 如果是网页 HTML 导航请求,提供友好降级返回主节点
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, `<p>与子节点连接失败,正在自动返回主节点...</p><script>document.cookie="` + constant.CookieActiveInterconnectNodeID + `=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; window.location.href="/";</script>`)
c.Abort()
return
}
c.JSON(502, gin.H{"code": 502, "msg": "与子节点逆向隧道通信异常: " + err.Error()})
c.Abort()
return
}
c.Abort()
return
}
// 走普通 HTTP 直连代理
targetURL := strings.TrimRight(node.URL, "/") + targetPath
if c.Request.URL.RawQuery != "" {
targetURL += "?" + c.Request.URL.RawQuery
}
req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body)
if err != nil {
c.JSON(500, gin.H{"code": 500, "msg": "Failed to create proxy request"})
c.Abort()
return
}
// 复制请求头
req.Header = c.Request.Header.Clone()
// 覆盖认证授权 Header 确保子节点鉴权通过
if node.Token != "" {
req.Header.Set("Authorization", "Bearer "+node.Token)
}
// 移除 Cookie 头部防干扰
req.Header.Del("Cookie")
req.Header.Set("X-Tunnel-Proxy", "true")
resp, err := travelProxyClient.Do(req)
if err != nil {
// 如果是客户端自己主动取消了请求(例如连续刷新、关闭网页等),直接退出,不应视为子节点离线而执行退回主节点的操作
if c.Request.Context().Err() == context.Canceled || strings.Contains(err.Error(), "context canceled") {
c.Abort()
return
}
if strings.Contains(c.GetHeader("Accept"), "text/html") {
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, `<p>与子节点连接失败,正在自动返回主节点...</p><script>document.cookie="` + constant.CookieActiveInterconnectNodeID + `=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; window.location.href="/";</script>`)
c.Abort()
return
}
c.JSON(502, gin.H{"code": 502, "msg": "无法连接至目标子节点: " + err.Error()})
c.Abort()
return
}
defer resp.Body.Close()
// 复制响应头
for k, v := range resp.Header {
for _, vv := range v {
c.Writer.Header().Add(k, vv)
}
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
c.Abort()
}
}
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"github.com/engigu/baihu-panel/internal/constant"
)
// NodeMetrics 表示互联节点的性能指标,使用 JSON 存储
type NodeMetrics struct {
CPUPercent float64 `json:"cpu_percent"`
MemPercent float64 `json:"mem_percent"`
DiskPercent float64 `json:"disk_percent"`
TxBytes uint64 `json:"tx_bytes,omitempty"`
RxBytes uint64 `json:"rx_bytes,omitempty"`
}
// InterconnectNode represents a connected remote panel
type InterconnectNode struct {
ID string `json:"id" gorm:"primaryKey;size:20"`
Name string `json:"name" gorm:"size:255;not null"`
URL string `json:"url" gorm:"size:255;not null"`
Token string `json:"token" gorm:"size:255"`
Remark string `json:"remark" gorm:"size:500"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
Status string `json:"status" gorm:"size:50"` // online / offline
Metrics NodeMetrics `json:"metrics" gorm:"serializer:json"`
LastHeartbeatAt *LocalTime `json:"last_heartbeat_at"`
}
func (InterconnectNode) TableName() string {
return constant.TablePrefix + "interconnect_nodes"
}
+35 -3
View File
@@ -1,9 +1,9 @@
package router
import (
// "github.com/engigu/baihu-panel/internal/controllers"
"github.com/engigu/baihu-panel/internal/middleware"
"runtime"
"github.com/engigu/baihu-panel/internal/middleware"
"github.com/gin-gonic/gin"
)
@@ -13,6 +13,12 @@ func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) {
ctx.JSON(200, gin.H{"message": "pong"})
})
api.GET("/debug/goroutines", func(ctx *gin.Context) {
buf := make([]byte, 1024*1024)
n := runtime.Stack(buf, true)
ctx.Data(200, "text/plain; charset=utf-8", buf[:n])
})
// Authentication routes (无需认证)
auth := api.Group("/auth")
{
@@ -24,6 +30,11 @@ func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) {
// 公开的站点设置(无需认证)
api.GET("/settings/public", c.Settings.GetPublicSiteSettings)
// 隧道模式 (被控端反向连入,使用独立 Token 做 WebSocket 鉴权)
api.GET("/interconnect/tunnel", c.Interconnect.HandleTunnel)
// 子节点主动上报监控数据 (无中间件鉴权,内部鉴权)
api.POST("/interconnect/report", c.Interconnect.ReportMonitorData)
// 内部使用的 API(仅限本地调用,无需 Bearer 认证)
internalAPI := api.Group("/internal")
internalAPI.Use(middleware.LocalhostOnly())
@@ -66,6 +77,7 @@ func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) {
registerSystemWSRoutes(adminOnly, c)
registerWebUIRoutes(adminOnly, c)
registerMonitorRoutes(adminOnly, c)
registerInterconnectRoutes(adminOnly, c)
}
}
@@ -83,6 +95,7 @@ func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) {
tasks.POST("", c.Task.CreateTask)
tasks.GET("", c.Task.GetTasks)
tasks.GET("/:id", c.Task.GetTask)
tasks.POST("/bulk_save", c.Task.BulkSaveTask)
tasks.PUT("/:id", c.Task.UpdateTask)
tasks.DELETE("/:id", c.Task.DeleteTask)
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
@@ -273,7 +286,7 @@ func registerMonitorRoutes(g *gin.RouterGroup, c *Controllers) {
monitor := g.Group("/monitor")
{
monitor.GET("", c.Monitor.GetSystemMonitor)
monitor.GET("/ws", c.Monitor.MonitorWS)
monitor.GET("/sse", c.Monitor.MonitorSSE)
}
}
@@ -298,3 +311,22 @@ func registerWebUIRoutes(g *gin.RouterGroup, c *Controllers) {
webuiGroup.DELETE("/:name", c.WebUI.DeleteWebUI)
}
}
func registerInterconnectRoutes(g *gin.RouterGroup, c *Controllers) {
interconnect := g.Group("/interconnect")
{
interconnect.GET("/nodes", c.Interconnect.GetNodes)
interconnect.POST("/nodes", c.Interconnect.CreateNode)
interconnect.PUT("/nodes/:id", c.Interconnect.UpdateNode)
interconnect.DELETE("/nodes/:id", c.Interconnect.DeleteNode)
interconnect.GET("/nodes/:id/status", c.Interconnect.GetNodeStatus)
interconnect.POST("/sync/script", c.Interconnect.SyncScript)
interconnect.POST("/sync/env", c.Interconnect.SyncEnv)
interconnect.POST("/sync/task", c.Interconnect.SyncTask)
interconnect.GET("/child/status", c.Interconnect.GetChildStatus)
// 代理模式 (面板穿越)
interconnect.Any("/proxy/:node_id/*path", c.Interconnect.ProxyRequest)
}
}
+4 -8
View File
@@ -2,13 +2,13 @@ package router
import (
// "fmt"
"time"
// "github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/eventbus"
// "github.com/engigu/baihu-panel/internal/logger"
// "github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/executor"
)
func setupEventHandlers(subscribers ...eventbus.Subscriber) {
@@ -21,12 +21,8 @@ func setupEventHandlers(subscribers ...eventbus.Subscriber) {
}
func startAppLogCleanup(appLogSvc *services.AppLogService) {
// 初始化时执行一次清理
appLogSvc.CleanUp()
// 定期清理(每隔1小时执行一次巡检)
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
// 注册到内部系统定时器(并立即执行一次
executor.GetSysCron().AddJobWithRun("@every 1h", func() {
appLogSvc.CleanUp()
}
})
}
+3 -1
View File
@@ -29,6 +29,7 @@ func RegisterControllers() *Controllers {
// 创建任务执行服务(需要依赖注入)
notifyService := services.NewNotificationService()
appLogService := services.NewAppLogService()
interconnectService := services.NewInterconnectService()
// 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理
// 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理
@@ -42,7 +43,7 @@ func RegisterControllers() *Controllers {
// 初始化所有关注系统总线的服务
setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager)
go startAppLogCleanup(appLogService)
startAppLogCleanup(appLogService)
// 初始化并返回控制器
return &Controllers{
@@ -65,6 +66,7 @@ func RegisterControllers() *Controllers {
SystemWS: controllers.NewSystemWSController(),
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
Monitor: controllers.NewMonitorController(executorService),
Interconnect: controllers.NewInterconnectController(interconnectService),
}
}
+2
View File
@@ -32,6 +32,7 @@ type Controllers struct {
SystemWS *controllers.SystemWSController
WebUI *controllers.WebUIController
Monitor *controllers.MonitorController
Interconnect *controllers.InterconnectController
}
func Setup(c *Controllers) *gin.Engine {
@@ -40,6 +41,7 @@ func Setup(c *Controllers) *gin.Engine {
}
router := gin.New()
router.Use(middleware.GinLogger(), middleware.GinRecovery())
router.Use(middleware.TravelProxyMiddleware())
// 获取 URL 前缀
cfg := services.GetConfig()
+43 -53
View File
@@ -7,6 +7,7 @@ import (
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/executor"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/models"
@@ -78,7 +79,11 @@ func GetAgentWSManager() *AgentWSManager {
ipFailCount: make(map[string]int),
remoteWaiters: make(map[string]chan *models.AgentTaskResult),
}
go agentWSManager.cleanupLoop()
// 启动时,先将所有 "online" 状态的 Agent 重置为 "offline"
NewAgentService().ResetAllAgentsToOffline()
// 将清理任务注册到系统内部 Cron,每 30 秒执行一次
executor.GetSysCron().AddJob("@every 30s", agentWSManager.cleanupLoop)
})
return agentWSManager
}
@@ -282,66 +287,51 @@ func (m *AgentWSManager) OnlineCount() int {
return len(m.connections)
}
// cleanupLoop 清理超时连接
// cleanupLoop 清理超时连接 (由 SysCron 每 30 秒调用一次)
func (m *AgentWSManager) cleanupLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
defer func() {
if r := recover(); r != nil {
logger.Errorf("[AgentWS] cleanupLoop panic: %v", r)
}
}()
// 启动时,先将所有 "online" 状态的 Agent 重置为 "offline"
// 因为 WebSocket 连接在应用启动时是空的,所有 Agent 客观上都是离线状态
// 等它们重新连接上来后,会变为 "online"
NewAgentService().ResetAllAgentsToOffline()
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for range ticker.C {
func() {
defer func() {
if r := recover(); r != nil {
logger.Errorf("[AgentWS] cleanupLoop panic: %v", r)
}
}()
m.mu.Lock()
now := time.Now()
// 清理超时连接
for agentID, conn := range m.connections {
if now.Sub(conn.LastPing) > 2*time.Minute {
// 减少 IP 连接计数
if conn.IP != "" {
if count, ok := m.ipConnections[conn.IP]; ok && count > 0 {
m.ipConnections[conn.IP] = count - 1
}
}
conn.Close()
delete(m.connections, agentID)
// 更新数据库状态
database.DB.Model(&models.Agent{}).Where("id = ?", agentID).Update("status", constant.AgentStatusOffline)
logger.Infof("[AgentWS] Agent #%s 心跳超时,已断开", agentID)
// 清理超时连接
for agentID, conn := range m.connections {
if now.Sub(conn.LastPing) > 2*time.Minute {
// 减少 IP 连接计数
if conn.IP != "" {
if count, ok := m.ipConnections[conn.IP]; ok && count > 0 {
m.ipConnections[conn.IP] = count - 1
}
}
conn.Close()
delete(m.connections, agentID)
// 更新数据库状态
database.DB.Model(&models.Agent{}).Where("id = ?", agentID).Update("status", constant.AgentStatusOffline)
logger.Infof("[AgentWS] Agent #%s 心跳超时,已断开", agentID)
}
}
// 定期清理数据库中的过期状态(处理服务重启或异常终止的情况)
// 有些 Agent 虽然没有连接,但数据库状态可能是 "online"
cutoff := now.Add(-2 * time.Minute)
database.DB.Model(&models.Agent{}).
Where("status = ? AND last_seen < ?", constant.AgentStatusOnline, cutoff).
Update("status", constant.AgentStatusOffline)
// 定期清理数据库中的过期状态(处理服务重启或异常终止的情况)
cutoff := now.Add(-2 * time.Minute)
database.DB.Model(&models.Agent{}).
Where("status = ? AND last_seen < ?", constant.AgentStatusOnline, cutoff).
Update("status", constant.AgentStatusOffline)
// 清理过期的限流记录(超过 10 分钟未活动)
// 清理过期的限流记录(超过 10 分钟未活动)
for ip, lastAttempt := range m.ipLastAttempt {
if now.Sub(lastAttempt) > 10*time.Minute {
delete(m.ipLastAttempt, ip)
delete(m.ipFailCount, ip)
// 只清理没有活跃连接的 IP 计数
if m.ipConnections[ip] == 0 {
delete(m.ipConnections, ip)
}
}
// 清理过期的限流记录(超过 10 分钟未活动)
for ip, lastAttempt := range m.ipLastAttempt {
if now.Sub(lastAttempt) > 10*time.Minute {
delete(m.ipLastAttempt, ip)
delete(m.ipFailCount, ip)
// 只清理没有活跃连接的 IP 计数
if m.ipConnections[ip] == 0 {
delete(m.ipConnections, ip)
}
m.mu.Unlock()
}()
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package services
import (
"strings"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/utils"
)
type InterconnectService struct{}
func NewInterconnectService() *InterconnectService {
return &InterconnectService{}
}
func (s *InterconnectService) GetNodes() ([]*models.InterconnectNode, error) {
var nodes []*models.InterconnectNode
err := database.DB.Find(&nodes).Error
return nodes, err
}
func (s *InterconnectService) GetNodeByID(id string) (*models.InterconnectNode, error) {
var node models.InterconnectNode
err := database.DB.Where("id = ?", id).First(&node).Error
return &node, err
}
func (s *InterconnectService) CreateNode(name, url, token, remark string) (*models.InterconnectNode, error) {
nodeID := utils.GenerateID()
if url == "" {
url = "tunnel://" + nodeID
}
node := &models.InterconnectNode{
ID: nodeID,
Name: name,
URL: url,
Token: strings.ToLower(token),
Remark: remark,
CreatedAt: models.Now(),
UpdatedAt: models.Now(),
}
err := database.DB.Create(node).Error
return node, err
}
func (s *InterconnectService) UpdateNode(id, name, url, token, remark string) (*models.InterconnectNode, error) {
node, err := s.GetNodeByID(id)
if err != nil {
return nil, err
}
node.Name = name
if url != "" {
node.URL = url
}
node.Token = token
node.Remark = remark
node.UpdatedAt = models.Now()
err = database.DB.Save(node).Error
return node, err
}
func (s *InterconnectService) DeleteNode(id string) error {
return database.DB.Where("id = ?", id).Delete(&models.InterconnectNode{}).Error
}
func (s *InterconnectService) GetNodeByToken(token string) (*models.InterconnectNode, error) {
var node models.InterconnectNode
err := database.DB.Where("token = ?", token).First(&node).Error
return &node, err
}
func (s *InterconnectService) UpdateNodeMonitorData(id string, metrics models.NodeMetrics) error {
now := models.Now()
return database.DB.Model(&models.InterconnectNode{}).
Where("id = ?", id).
Select("status", "metrics", "last_heartbeat_at", "updated_at").
Updates(models.InterconnectNode{
Status: "online",
Metrics: metrics,
LastHeartbeatAt: &now,
UpdatedAt: now,
}).Error
}
+132
View File
@@ -0,0 +1,132 @@
package services
import (
"math/rand"
"runtime"
"sync"
"time"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
)
type HostMetrics struct {
CPUPercent float64
VMem *mem.VirtualMemoryStat
DiskUsage *disk.UsageStat
HostInfo *host.InfoStat
}
type MonitorService struct {
hostMu sync.RWMutex
lastUpdate time.Time
metrics HostMetrics
}
var (
monitorServiceInstance *MonitorService
monitorServiceOnce sync.Once
)
// GetMonitorService 获取系统监控服务单例
func GetMonitorService() *MonitorService {
monitorServiceOnce.Do(func() {
monitorServiceInstance = &MonitorService{}
})
return monitorServiceInstance
}
// GetHostMetrics 获取并返回物理机状态(带有缓存和演示模式伪装)
func (ms *MonitorService) GetHostMetrics() HostMetrics {
ms.hostMu.Lock()
defer ms.hostMu.Unlock()
// 缓存 2 秒
if time.Since(ms.lastUpdate) < 2*time.Second && ms.metrics.VMem != nil {
return ms.metrics
}
if constant.DemoMode {
ms.updateDemoMetrics()
return ms.metrics
}
cpuPercents, _ := cpu.Percent(0, false)
if len(cpuPercents) > 0 {
ms.metrics.CPUPercent = cpuPercents[0]
}
ms.metrics.VMem, _ = mem.VirtualMemory()
ms.metrics.DiskUsage, _ = disk.Usage("/")
ms.metrics.HostInfo, _ = host.Info()
ms.lastUpdate = time.Now()
// 提供默认值防空指针
if ms.metrics.VMem == nil {
ms.metrics.VMem = &mem.VirtualMemoryStat{}
}
if ms.metrics.DiskUsage == nil {
ms.metrics.DiskUsage = &disk.UsageStat{}
}
if ms.metrics.HostInfo == nil {
ms.metrics.HostInfo = &host.InfoStat{}
}
return ms.metrics
}
type RuntimeMetrics struct {
NumGoroutine int
MemStats runtime.MemStats
}
var (
runtimeMu sync.RWMutex
lastRuntime time.Time
cachedRuntime RuntimeMetrics
)
// GetRuntimeMetrics 获取 Go 运行时指标(缓存 2 秒,防止高并发下频繁触发 STW)
func (ms *MonitorService) GetRuntimeMetrics() RuntimeMetrics {
runtimeMu.Lock()
defer runtimeMu.Unlock()
if time.Since(lastRuntime) < 2*time.Second && cachedRuntime.NumGoroutine > 0 {
return cachedRuntime
}
cachedRuntime.NumGoroutine = runtime.NumGoroutine()
runtime.ReadMemStats(&cachedRuntime.MemStats)
lastRuntime = time.Now()
return cachedRuntime
}
func (ms *MonitorService) updateDemoMetrics() {
ms.metrics.CPUPercent = 10 + rand.Float64()*40 // 10% - 50% 的随机 CPU 波动
totalMem := uint64(8 * 1024 * 1024 * 1024) // 8GB 内存
usedMem := uint64(float64(totalMem) * (0.3 + rand.Float64()*0.3)) // 30% - 60% 随机使用率
ms.metrics.VMem = &mem.VirtualMemoryStat{
Total: totalMem,
Used: usedMem,
UsedPercent: float64(usedMem) / float64(totalMem) * 100,
}
totalDisk := uint64(500 * 1024 * 1024 * 1024) // 500GB 硬盘
usedDisk := uint64(float64(totalDisk) * 0.45) // 固定 45% 使用率
ms.metrics.DiskUsage = &disk.UsageStat{
Total: totalDisk,
Used: usedDisk,
UsedPercent: float64(usedDisk) / float64(totalDisk) * 100,
}
ms.metrics.HostInfo = &host.InfoStat{
Platform: "Demo Environment",
OS: "linux",
Uptime: uint64(time.Now().Unix() - 1700000000), // 生成一个较长且持续增加的运行时间
}
ms.lastUpdate = time.Now()
}
+254
View File
@@ -0,0 +1,254 @@
package tunnel
import (
"crypto/tls"
"fmt"
"math/rand"
"net/http"
"net/url"
"sync"
"time"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/services"
"github.com/gorilla/websocket"
"github.com/hashicorp/yamux"
)
var (
tunnelActive bool
clientConn *yamux.Session
clientMu sync.Mutex
stopCh chan struct{}
// LocalEngine 保存全局的 Gin 引擎或 HTTP 处理器,用于接收主节点的隧道请求并进行纯内存函数路由
LocalEngine http.Handler
)
// SetLocalEngine 由 bootstrap 层注入
func SetLocalEngine(engine http.Handler) {
LocalEngine = engine
}
// StartClient 尝试连接到父节点面板(如果已配置)
func StartClient() {
clientMu.Lock()
if tunnelActive {
clientMu.Unlock()
return
}
tunnelActive = true
stopCh = make(chan struct{})
clientMu.Unlock()
go runClientLoop()
}
// StopClient 停止后台的隧道客户端
func StopClient() {
clientMu.Lock()
if !tunnelActive {
clientMu.Unlock()
return
}
tunnelActive = false
if stopCh != nil {
close(stopCh)
}
if clientConn != nil {
clientConn.Close()
}
clientMu.Unlock()
}
// Init 从数据库加载配置并初始化对应的后台隧道服务
func Init() {
siteConfig := services.NewSettingsService().GetSection(constant.SectionInterconnect)
role := siteConfig[constant.KeyInterconnectRole]
ApplyRole(role)
}
// ApplyRole 根据互联角色动态启停背景协程服务
func ApplyRole(role string) {
switch role {
case constant.InterconnectRoleMaster:
// 主控角色:无需后台轮询,等待子节点上报即可
StopClient()
case constant.InterconnectRoleChild:
// 子节点:关闭可能存在的主控会话,启动连接守护
CloseAllSessions()
StartClient()
default:
// 未开启或离线
StopClient()
CloseAllSessions()
}
}
// IsTunnelConnected 返回当前子节点隧道是否已连接到主节点
func IsTunnelConnected() bool {
clientMu.Lock()
defer clientMu.Unlock()
if !tunnelActive || clientConn == nil {
return false
}
return !clientConn.IsClosed()
}
func runClientLoop() {
settingsSvc := services.NewSettingsService()
const (
initialBackoff = 5 * time.Second
maxBackoff = 300 * time.Second
fatalBackoff = 60 * time.Second
)
backoff := initialBackoff
retryCount := 0
var lastErrorMsg string
for {
clientMu.Lock()
if !tunnelActive {
clientMu.Unlock()
return
}
clientMu.Unlock()
siteConfig := settingsSvc.GetSection(constant.SectionInterconnect)
role := siteConfig[constant.KeyInterconnectRole]
if role != constant.InterconnectRoleChild {
time.Sleep(10 * time.Second)
continue
}
parentURL := siteConfig[constant.KeyInterconnectParentURL]
parentToken := siteConfig[constant.KeyInterconnectParentToken]
if parentURL == "" || parentToken == "" {
time.Sleep(10 * time.Second)
continue
}
u, err := url.Parse(parentURL)
if err != nil {
logger.Errorf("[Tunnel] 无效的主节点 URL: %v", err)
time.Sleep(10 * time.Second)
continue
}
scheme := "ws"
if u.Scheme == "https" {
scheme = "wss"
}
wsURL := fmt.Sprintf("%s://%s/api/v1/interconnect/tunnel", scheme, u.Host)
header := http.Header{}
header.Set("Authorization", "Bearer "+parentToken)
dialer := &websocket.Dialer{
Proxy: http.ProxyFromEnvironment,
HandshakeTimeout: 45 * time.Second,
}
dialer.EnableCompression = true
if u.Scheme == "https" {
dialer.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
conn, resp, err := dialer.Dial(wsURL, header)
if err != nil {
retryCount++
errMsg := err.Error()
// 特殊处理致命的认证或配置错误 (仅401作为致命错误,403使用指数退避)
isFatal := resp != nil && resp.StatusCode == 401
if isFatal {
backoff = fatalBackoff
}
// 先计算退避时间 (加上 0-8 秒随机抖动防止风暴)
jitter := time.Duration(rand.Intn(8000)) * time.Millisecond
sleepDuration := backoff + jitter
// 日志记录
if retryCount == 1 || errMsg != lastErrorMsg {
logger.Errorf("[Tunnel] 连接主节点隧道失败: %v, 下次重试将在 %v 后", err, sleepDuration.Round(time.Second))
} else {
logger.Warnf("[Tunnel] 仍无法连接主节点,已尝试 %d 次, 下次重试将在 %v 后", retryCount, sleepDuration.Round(time.Second))
}
lastErrorMsg = errMsg
time.Sleep(sleepDuration)
// 指数级增加退避时间 (最大不超过 maxBackoff)
if !isFatal && backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
continue
}
// 连接成功,重置退避状态
retryCount = 0
lastErrorMsg = ""
backoff = initialBackoff
logger.Infof("[Tunnel] 已成功连接到主节点隧道: %s", wsURL)
netConn := NetConn(conn)
trackedConn := &trafficCounterConn{Conn: netConn}
conf := yamux.DefaultConfig()
conf.EnableKeepAlive = true
// 我们是子节点,接受来自主节点的请求。
// 因此在这里,我们是 Yamux Server,负责监听传入的流。
session, err := yamux.Server(trackedConn, conf)
if err != nil {
logger.Errorf("[Tunnel] 启动 Yamux Server 失败: %v", err)
trackedConn.Close()
time.Sleep(5 * time.Second)
continue
}
// 如果已经存在上一个活跃的隧道连接,在重建前必须将其显式 Close(),以释放该会话在后台运行的 HTTP 监听与监听协程
clientMu.Lock()
if clientConn != nil {
clientConn.Close()
}
clientConn = session
clientMu.Unlock()
services.GetSystemWSManager().Broadcast(constant.EventInterconnectChildStatus, map[string]interface{}{
"connected": true,
})
// 启动子节点主动上报服务
StartReporter(parentURL, parentToken)
// 启动本地 HTTP 代理服务 (阻塞直到会话关闭)
serveLocalProxy(session)
// 断开时停止上报
StopReporter()
session.Close()
services.GetSystemWSManager().Broadcast(constant.EventInterconnectChildStatus, map[string]interface{}{
"connected": false,
})
logger.Infof("[Tunnel] 已从主节点隧道断开连接")
// 断开后稍微缓冲一下再去重试
time.Sleep(2 * time.Second)
}
}
+87
View File
@@ -0,0 +1,87 @@
package tunnel
import (
"io"
"net"
"time"
"github.com/gorilla/websocket"
)
// wsConn 包装 gorilla websocket 以实现 net.Conn 接口
type wsConn struct {
conn *websocket.Conn
reader io.Reader
}
// NetConn 将 WebSocket 连接转换为标准 net.Conn
func NetConn(conn *websocket.Conn) net.Conn {
return &wsConn{
conn: conn,
}
}
func (c *wsConn) Read(b []byte) (n int, err error) {
for {
if c.reader == nil {
_, r, err := c.conn.NextReader()
if err != nil {
return 0, err
}
c.reader = r
}
n, err = c.reader.Read(b)
if err == io.EOF {
c.reader = nil
if n > 0 {
return n, nil
}
continue
}
return n, err
}
}
func (c *wsConn) Write(b []byte) (n int, err error) {
// Yamux 保证了对底层连接的序列化写入(不会出现并发写),
// 因此我们这里不需要加互斥锁 (Mutex)。
w, err := c.conn.NextWriter(websocket.BinaryMessage)
if err != nil {
return 0, err
}
n, err = w.Write(b)
// 必须关闭 writer 以触发 WebSocket 的消息帧封包发送
closeErr := w.Close()
if err == nil {
err = closeErr
}
return n, err
}
func (c *wsConn) Close() error {
return c.conn.Close()
}
func (c *wsConn) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *wsConn) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
func (c *wsConn) SetDeadline(t time.Time) error {
if err := c.conn.SetReadDeadline(t); err != nil {
return err
}
return c.conn.SetWriteDeadline(t)
}
func (c *wsConn) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}
func (c *wsConn) SetWriteDeadline(t time.Time) error {
return c.conn.SetWriteDeadline(t)
}
+109
View File
@@ -0,0 +1,109 @@
package tunnel
import (
"errors"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/gin-gonic/gin"
"github.com/hashicorp/yamux"
)
// ProxyHTTP 以完全透明的流式转发将 HTTP 请求代理到远程节点 (主节点调用)
func ProxyHTTP(nodeID string, c *gin.Context, targetPath string) error {
sess := GetSession(nodeID)
if sess == nil {
return errors.New("node is offline or tunnel not established")
}
if sess.Token == "" {
return errors.New("node token not found in session cache")
}
// 获取真实客户端 IP,传递给子节点用于审计日志等
clientIP := c.ClientIP()
director := func(req *http.Request) {
req.URL.Scheme = "http"
req.URL.Host = "tunnel.local"
req.URL.Path = targetPath
// gin 和 httputil 默认会保留并透传 Query 参数
req.Header.Set("Authorization", "Bearer "+sess.Token)
// 删除父节点的 Cookie 避免干扰子节点的会话验证
req.Header.Del("Cookie")
// 强制加上该 Header,防止出现节点相互代理的死循环
req.Header.Set("X-Tunnel-Proxy", "true")
// 传递真实的客户端 IP 给子节点
if clientIP != "" {
req.Header.Set("X-Forwarded-For", clientIP)
}
}
proxy := &httputil.ReverseProxy{
Director: director,
Transport: sess.Transport, // 直接复用 AddSession 时创建的全局连接池,由原生库处理 Context 取消
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
logger.Errorf("[Tunnel] 节点 %s 代理隧道请求发生错误: %v", nodeID, err)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("Tunnel connection error: " + err.Error()))
},
}
proxy.ServeHTTP(c.Writer, c.Request)
return nil
}
// serveLocalProxy 本地服务代理逻辑 (子节点调用)
func serveLocalProxy(session *yamux.Session) {
if LocalEngine == nil {
logger.Errorf("[Tunnel] 无法启动本地代理服务,因为 LocalEngine 尚未注入。")
return
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 安全规则 1:防止互相调用的死循环
if strings.HasPrefix(r.URL.Path, "/api/v1/interconnect/proxy/") {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Recursive proxy loops are not allowed via tunnel"))
return
}
// 安全规则 2:严格限制只能访问本机的 /api/v1/ 接口
if !strings.HasPrefix(r.URL.Path, "/api/v1/") {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Tunnel access is strictly restricted to /api/v1/ endpoints"))
return
}
// 确保保留 X-Tunnel-Proxy 请求头,以防后续逻辑需要判定
r.Header.Set("X-Tunnel-Proxy", "true")
// 纯内存函数调用,直接扔给 Gin 引擎,零网络开销!
LocalEngine.ServeHTTP(w, r)
})
server := &http.Server{
Handler: handler,
ReadTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second,
}
// 监听会话关闭信号,主动关闭 http.Server 以彻底释放协程
go func() {
<-session.CloseChan()
server.Close()
}()
// yamux.Session 实现了标准的 net.Listener 接口!
err := server.Serve(session)
if err != nil && err != http.ErrServerClosed && err != yamux.ErrSessionShutdown {
logger.Errorf("[Tunnel] Yamux 代理服务意外停止: %v", err)
}
}
+117
View File
@@ -0,0 +1,117 @@
package tunnel
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/engigu/baihu-panel/internal/executor"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/services"
"github.com/robfig/cron/v3"
)
var (
reporterCronID cron.EntryID
isReporting bool
localTunnelURL string
localTunnelURLMutex sync.RWMutex
)
// GetLocalTunnelURL 返回当前子节点被分配的隧道地址
func GetLocalTunnelURL() string {
localTunnelURLMutex.RLock()
defer localTunnelURLMutex.RUnlock()
return localTunnelURL
}
// StartReporter 启动子节点上报守护进程
func StartReporter(parentURL, token string) {
if isReporting {
return
}
isReporting = true
// 修整 parentURL 确保正确指向 /api/v1/interconnect/report
// 如果用户填的是 http://192.168.1.100:8000,我们要加上路径
baseURL := strings.TrimRight(parentURL, "/")
// 注意兼容如果用户带了 /api/v1
if !strings.HasSuffix(baseURL, "/api/v1") && !strings.Contains(baseURL, "/api/v1") {
baseURL = baseURL + "/api/v1"
}
reportURL := baseURL + "/interconnect/report"
// 使用全局 SysCron 并立即上报一次,随后每 45 秒循环一次
id, err := executor.GetSysCron().AddJobWithRun("@every 45s", func() {
reportMonitorData(reportURL, token)
})
if err == nil {
reporterCronID = id
} else {
logger.Warnf("[Tunnel] 无法将上报任务加入 SysCron: %v", err)
}
}
// StopReporter 停止子节点上报守护进程
func StopReporter() {
if isReporting {
if reporterCronID != 0 {
executor.GetSysCron().RemoveJob(reporterCronID)
reporterCronID = 0
}
isReporting = false
}
}
func reportMonitorData(reportURL, token string) {
// 调用单例监控服务获取底层系统状态
metrics := services.GetMonitorService().GetHostMetrics()
payload := map[string]interface{}{
"cpu_percent": metrics.CPUPercent,
"mem_percent": metrics.VMem.UsedPercent,
"disk_percent": metrics.DiskUsage.UsedPercent,
"tx_bytes": GetTxBytes(),
"rx_bytes": GetRxBytes(),
}
data, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", reportURL, bytes.NewBuffer(data))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Warnf("[Tunnel] 上报监控数据失败: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Warnf("[Tunnel] 上报监控数据返回异常状态码: %d", resp.StatusCode)
return
}
var res struct {
Data struct {
TunnelURL string `json:"tunnel_url"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
if res.Data.TunnelURL != "" {
localTunnelURLMutex.Lock()
localTunnelURL = res.Data.TunnelURL
localTunnelURLMutex.Unlock()
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package tunnel
import (
"strings"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/logger"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/services"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"net/http"
"github.com/hashicorp/yamux"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
EnableCompression: true, // 启用协议级别的 Deflate 数据压缩
}
func HandleTunnel(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(401, gin.H{"error": "missing authorization"})
return
}
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if tokenStr == "" {
c.JSON(401, gin.H{"error": "invalid token format"})
return
}
settingsSvc := services.NewSettingsService()
siteConfig := settingsSvc.GetSection(constant.SectionInterconnect)
role := siteConfig[constant.KeyInterconnectRole]
if role != constant.InterconnectRoleMaster {
c.JSON(403, gin.H{"error": "interconnect master role not enabled on this server"})
return
}
var node models.InterconnectNode
if err := database.DB.Where("token = ?", tokenStr).First(&node).Error; err != nil {
c.JSON(401, gin.H{"error": "invalid interconnect token"})
return
}
nodeID := node.ID
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Errorf("[Tunnel] 升级隧道连接失败 (NodeID: %s): %v", nodeID, err)
return
}
// 将 WebSocket 包装为标准的 net.Conn
netConn := NetConn(conn)
// 获取客户端真实 IP 和端口以更新连接地址,方便面板显示
clientIP := c.ClientIP()
port := "unknown"
if parts := strings.Split(netConn.RemoteAddr().String(), ":"); len(parts) > 1 {
port = parts[len(parts)-1]
}
realURL := "tunnel://" + clientIP + ":" + port
if node.URL != realURL {
database.DB.Model(&node).Update("url", realURL)
}
// 我们是主节点(父节点),主动发起对子节点的请求。
// 因此在 Yamux 协议中我们扮演 Client,而子节点扮演 Server。
conf := yamux.DefaultConfig()
conf.EnableKeepAlive = true
session, err := yamux.Client(netConn, conf)
if err != nil {
logger.Errorf("[Tunnel] 启动 Yamux Client 失败 (NodeID: %s): %v", nodeID, err)
netConn.Close()
return
}
AddSession(nodeID, tokenStr, session)
logger.Infof("[Tunnel] 已建立来自子节点的隧道连接: %s (%s)", nodeID, node.Name)
go func(s *yamux.Session) {
<-s.CloseChan()
logger.Infof("[Tunnel] 子节点隧道连接已断开 (NodeID: %s, IsClosed: %v)", nodeID, s.IsClosed())
RemoveSession(nodeID, s)
}(session)
}
+91
View File
@@ -0,0 +1,91 @@
package tunnel
import (
"context"
"net"
"net/http"
"sync"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models"
"github.com/hashicorp/yamux"
)
// TunnelSession 表示一个基于 WebSocket 的 Yamux 活跃会话
type TunnelSession struct {
NodeID string
Token string
Session *yamux.Session
Transport *http.Transport
}
var (
sessions = make(map[string]*TunnelSession)
sessionsMu sync.RWMutex
)
func AddSession(nodeID string, token string, session *yamux.Session) *TunnelSession {
sessionsMu.Lock()
defer sessionsMu.Unlock()
// 关闭已经存在的旧会话
if old, exists := sessions[nodeID]; exists {
old.Session.Close()
old.Transport.CloseIdleConnections()
}
tr := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return session.Open()
},
DisableKeepAlives: true,
}
sess := &TunnelSession{
NodeID: nodeID,
Token: token,
Session: session,
Transport: tr,
}
sessions[nodeID] = sess
return sess
}
func RemoveSession(nodeID string, session *yamux.Session) {
sessionsMu.Lock()
defer sessionsMu.Unlock()
if sess, exists := sessions[nodeID]; exists && sess.Session == session {
sess.Session.Close()
sess.Transport.CloseIdleConnections()
delete(sessions, nodeID)
// 节点下线时实时更新数据库状态
database.DB.Model(&models.InterconnectNode{}).
Where("id = ?", nodeID).
Update("status", "offline")
}
}
// CloseAllSessions 强制关闭所有现存的子节点会话(用于主控角色被取消时)
func CloseAllSessions() {
sessionsMu.Lock()
defer sessionsMu.Unlock()
for _, sess := range sessions {
if sess.Session != nil {
sess.Session.Close()
}
if sess.Transport != nil {
sess.Transport.CloseIdleConnections()
}
}
// 清空所有的会话
sessions = make(map[string]*TunnelSession)
}
func GetSession(nodeID string) *TunnelSession {
sessionsMu.RLock()
defer sessionsMu.RUnlock()
return sessions[nodeID]
}
+48
View File
@@ -0,0 +1,48 @@
package tunnel
import (
"net"
"sync/atomic"
)
var (
txBytes uint64
rxBytes uint64
)
// trafficCounterConn 包装了原生的 net.Conn,用于统计底层收发的真实物理字节数
type trafficCounterConn struct {
net.Conn
}
func (c *trafficCounterConn) Read(b []byte) (n int, err error) {
n, err = c.Conn.Read(b)
if n > 0 {
atomic.AddUint64(&rxBytes, uint64(n))
}
return
}
func (c *trafficCounterConn) Write(b []byte) (n int, err error) {
n, err = c.Conn.Write(b)
if n > 0 {
atomic.AddUint64(&txBytes, uint64(n))
}
return
}
// GetTxBytes 返回隧道累计发送的字节数
func GetTxBytes() uint64 {
return atomic.LoadUint64(&txBytes)
}
// GetRxBytes 返回隧道累计接收的字节数
func GetRxBytes() uint64 {
return atomic.LoadUint64(&rxBytes)
}
// ResetTrafficBytes 重置流量统计计数器
func ResetTrafficBytes() {
atomic.StoreUint64(&txBytes, 0)
atomic.StoreUint64(&rxBytes, 0)
}