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
@@ -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()
}