mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 17:24:05 +00:00
561 lines
15 KiB
Go
561 lines
15 KiB
Go
// Package controller provides HTTP handlers for node management in multi-node mode.
|
|
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// NodeController handles HTTP requests related to node management.
|
|
type NodeController struct {
|
|
nodeService service.NodeService
|
|
}
|
|
|
|
// NewNodeController creates a new NodeController and sets up its routes.
|
|
func NewNodeController(g *gin.RouterGroup) *NodeController {
|
|
a := &NodeController{
|
|
nodeService: service.NodeService{},
|
|
}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
// initRouter initializes the routes for node-related operations.
|
|
func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
|
g.GET("/list", a.getNodes)
|
|
g.GET("/get/:id", a.getNode)
|
|
g.POST("/add", a.addNode)
|
|
g.POST("/update/:id", a.updateNode)
|
|
g.POST("/del/:id", a.deleteNode)
|
|
g.POST("/check/:id", a.checkNode)
|
|
g.POST("/checkAll", a.checkAllNodes)
|
|
g.POST("/reload/:id", a.reloadNode)
|
|
g.POST("/reloadAll", a.reloadAllNodes)
|
|
g.GET("/status/:id", a.getNodeStatus)
|
|
g.POST("/logs/:id", a.getNodeLogs)
|
|
g.POST("/check-connection", a.checkNodeConnection) // Check node connection without API key
|
|
// push-logs endpoint moved to APIController to bypass session auth
|
|
}
|
|
|
|
// getNodes retrieves the list of all nodes.
|
|
func (a *NodeController) getNodes(c *gin.Context) {
|
|
nodes, err := a.nodeService.GetAllNodes()
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get nodes", err)
|
|
return
|
|
}
|
|
|
|
// Enrich nodes with assigned inbounds information
|
|
type NodeWithInbounds struct {
|
|
*model.Node
|
|
Inbounds []*model.Inbound `json:"inbounds,omitempty"`
|
|
}
|
|
|
|
result := make([]NodeWithInbounds, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
inbounds, _ := a.nodeService.GetInboundsForNode(node.Id)
|
|
result = append(result, NodeWithInbounds{
|
|
Node: node,
|
|
Inbounds: inbounds,
|
|
})
|
|
}
|
|
|
|
jsonObj(c, result, nil)
|
|
}
|
|
|
|
// getNode retrieves a specific node by its ID.
|
|
func (a *NodeController) getNode(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
node, err := a.nodeService.GetNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get node", err)
|
|
return
|
|
}
|
|
jsonObj(c, node, nil)
|
|
}
|
|
|
|
// addNode creates a new node and registers it with a generated API key.
|
|
func (a *NodeController) addNode(c *gin.Context) {
|
|
node := &model.Node{}
|
|
err := c.ShouldBind(node)
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node data", err)
|
|
return
|
|
}
|
|
|
|
// Log received data for debugging
|
|
logger.Debugf("[Node: %s] Adding node: address=%s", node.Name, node.Address)
|
|
|
|
// Note: Connection check is done on frontend via /panel/node/check-connection endpoint
|
|
// to avoid CORS issues. Here we proceed directly to registration.
|
|
|
|
// Generate API key and register node
|
|
apiKey, err := a.nodeService.RegisterNode(node)
|
|
if err != nil {
|
|
logger.Errorf("[Node: %s] Registration failed: %v", node.Name, err)
|
|
jsonMsg(c, "Failed to register node: "+err.Error(), err)
|
|
return
|
|
}
|
|
|
|
// Set the generated API key
|
|
node.ApiKey = apiKey
|
|
|
|
// Set default status
|
|
if node.Status == "" {
|
|
node.Status = "unknown"
|
|
}
|
|
|
|
// Save node to database
|
|
err = a.nodeService.AddNode(node)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to add node to database", err)
|
|
return
|
|
}
|
|
|
|
// Check health immediately
|
|
go a.nodeService.CheckNodeHealth(node)
|
|
|
|
// Broadcast nodes update via WebSocket
|
|
a.broadcastNodesUpdate()
|
|
|
|
logger.Infof("[Node: %s] Node added and registered successfully", node.Name)
|
|
jsonMsgObj(c, "Node added and registered successfully", node, nil)
|
|
}
|
|
|
|
// updateNode updates an existing node.
|
|
func (a *NodeController) updateNode(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
|
|
// Get existing node first to preserve fields that are not being updated
|
|
existingNode, err := a.nodeService.GetNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get existing node", err)
|
|
return
|
|
}
|
|
|
|
// Create node with only provided fields
|
|
node := &model.Node{Id: id}
|
|
|
|
// Try to parse as JSON first (for API calls)
|
|
contentType := c.GetHeader("Content-Type")
|
|
if contentType == "application/json" {
|
|
var jsonData map[string]interface{}
|
|
if err := c.ShouldBindJSON(&jsonData); err == nil {
|
|
// Only set fields that are provided in JSON
|
|
if nameVal, ok := jsonData["name"].(string); ok && nameVal != "" {
|
|
node.Name = nameVal
|
|
}
|
|
if addressVal, ok := jsonData["address"].(string); ok && addressVal != "" {
|
|
node.Address = addressVal
|
|
}
|
|
if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" {
|
|
node.ApiKey = apiKeyVal
|
|
}
|
|
// TLS settings
|
|
if useTlsVal, ok := jsonData["useTls"].(bool); ok {
|
|
node.UseTLS = useTlsVal
|
|
}
|
|
if certPathVal, ok := jsonData["certPath"].(string); ok {
|
|
node.CertPath = certPathVal
|
|
}
|
|
if keyPathVal, ok := jsonData["keyPath"].(string); ok {
|
|
node.KeyPath = keyPathVal
|
|
}
|
|
if insecureTlsVal, ok := jsonData["insecureTls"].(bool); ok {
|
|
node.InsecureTLS = insecureTlsVal
|
|
}
|
|
}
|
|
} else {
|
|
// Parse as form data (default for web UI)
|
|
// Only extract fields that are actually provided
|
|
if name := c.PostForm("name"); name != "" {
|
|
node.Name = name
|
|
}
|
|
if address := c.PostForm("address"); address != "" {
|
|
node.Address = address
|
|
}
|
|
if apiKey := c.PostForm("apiKey"); apiKey != "" {
|
|
node.ApiKey = apiKey
|
|
}
|
|
// TLS settings
|
|
node.UseTLS = c.PostForm("useTls") == "true" || c.PostForm("useTls") == "on"
|
|
if certPath := c.PostForm("certPath"); certPath != "" {
|
|
node.CertPath = certPath
|
|
}
|
|
if keyPath := c.PostForm("keyPath"); keyPath != "" {
|
|
node.KeyPath = keyPath
|
|
}
|
|
node.InsecureTLS = c.PostForm("insecureTls") == "true" || c.PostForm("insecureTls") == "on"
|
|
}
|
|
|
|
// Validate API key if it was changed
|
|
if node.ApiKey != "" && node.ApiKey != existingNode.ApiKey {
|
|
// Create a temporary node for validation
|
|
validationNode := &model.Node{
|
|
Id: id,
|
|
Address: node.Address,
|
|
ApiKey: node.ApiKey,
|
|
}
|
|
if validationNode.Address == "" {
|
|
validationNode.Address = existingNode.Address
|
|
}
|
|
err = a.nodeService.ValidateApiKey(validationNode)
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
err = a.nodeService.UpdateNode(node)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to update node", err)
|
|
return
|
|
}
|
|
|
|
// Broadcast nodes update via WebSocket
|
|
a.broadcastNodesUpdate()
|
|
|
|
jsonMsgObj(c, "Node updated successfully", node, nil)
|
|
}
|
|
|
|
// deleteNode deletes a node by its ID.
|
|
func (a *NodeController) deleteNode(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
|
|
err = a.nodeService.DeleteNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to delete node", err)
|
|
return
|
|
}
|
|
|
|
// Broadcast nodes update via WebSocket
|
|
a.broadcastNodesUpdate()
|
|
|
|
jsonMsg(c, "Node deleted successfully", nil)
|
|
}
|
|
|
|
// checkNode checks the health of a specific node.
|
|
func (a *NodeController) checkNode(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
|
|
node, err := a.nodeService.GetNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get node", err)
|
|
return
|
|
}
|
|
|
|
err = a.nodeService.CheckNodeHealth(node)
|
|
if err != nil {
|
|
jsonMsg(c, "Node health check failed", err)
|
|
return
|
|
}
|
|
|
|
// Broadcast nodes update via WebSocket (to update status and response time)
|
|
a.broadcastNodesUpdate()
|
|
|
|
jsonMsgObj(c, "Node health check completed", node, nil)
|
|
}
|
|
|
|
// checkAllNodes checks the health of all nodes.
|
|
func (a *NodeController) checkAllNodes(c *gin.Context) {
|
|
a.nodeService.CheckAllNodesHealth()
|
|
// Broadcast nodes update after health check (with delay to allow all checks to complete)
|
|
go func() {
|
|
time.Sleep(3 * time.Second) // Wait for health checks to complete
|
|
a.broadcastNodesUpdate()
|
|
}()
|
|
jsonMsg(c, "Health check initiated for all nodes", nil)
|
|
}
|
|
|
|
// getNodeStatus retrieves the detailed status of a node.
|
|
func (a *NodeController) getNodeStatus(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
|
|
node, err := a.nodeService.GetNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get node", err)
|
|
return
|
|
}
|
|
|
|
status, err := a.nodeService.GetNodeStatus(node)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get node status", err)
|
|
return
|
|
}
|
|
|
|
jsonObj(c, status, nil)
|
|
}
|
|
|
|
// reloadNode reloads XRAY on a specific node.
|
|
func (a *NodeController) reloadNode(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
|
|
node, err := a.nodeService.GetNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get node", err)
|
|
return
|
|
}
|
|
|
|
// Use force reload to handle hung nodes
|
|
err = a.nodeService.ForceReloadNode(node)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to reload node", err)
|
|
return
|
|
}
|
|
|
|
jsonMsg(c, "Node reloaded successfully", nil)
|
|
}
|
|
|
|
// reloadAllNodes reloads XRAY on all nodes.
|
|
func (a *NodeController) reloadAllNodes(c *gin.Context) {
|
|
err := a.nodeService.ReloadAllNodes()
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to reload some nodes", err)
|
|
return
|
|
}
|
|
|
|
jsonMsg(c, "All nodes reloaded successfully", nil)
|
|
}
|
|
|
|
// getNodeLogs retrieves XRAY logs from a specific node.
|
|
func (a *NodeController) getNodeLogs(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid node ID", err)
|
|
return
|
|
}
|
|
|
|
node, err := a.nodeService.GetNode(id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get node", err)
|
|
return
|
|
}
|
|
|
|
count := c.DefaultPostForm("count", "100")
|
|
filter := c.PostForm("filter")
|
|
showDirect := c.DefaultPostForm("showDirect", "true")
|
|
showBlocked := c.DefaultPostForm("showBlocked", "true")
|
|
showProxy := c.DefaultPostForm("showProxy", "true")
|
|
|
|
countInt, _ := strconv.Atoi(count)
|
|
|
|
// Get raw logs from node
|
|
rawLogs, err := a.nodeService.GetNodeLogs(node, countInt, filter)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to get logs from node", err)
|
|
return
|
|
}
|
|
|
|
// Parse logs into LogEntry format (similar to ServerService.GetXrayLogs)
|
|
type LogEntry struct {
|
|
DateTime time.Time `json:"DateTime"`
|
|
FromAddress string `json:"FromAddress"`
|
|
ToAddress string `json:"ToAddress"`
|
|
Inbound string `json:"Inbound"`
|
|
Outbound string `json:"Outbound"`
|
|
Email string `json:"Email"`
|
|
Event int `json:"Event"`
|
|
}
|
|
|
|
const (
|
|
Direct = iota
|
|
Blocked
|
|
Proxied
|
|
)
|
|
|
|
var freedoms []string
|
|
var blackholes []string
|
|
|
|
// Get tags for freedom and blackhole outbounds from default config
|
|
settingService := service.SettingService{}
|
|
config, err := settingService.GetDefaultXrayConfig()
|
|
if err == nil && config != nil {
|
|
if cfgMap, ok := config.(map[string]any); ok {
|
|
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
|
for _, outbound := range outbounds {
|
|
if obMap, ok := outbound.(map[string]any); ok {
|
|
switch obMap["protocol"] {
|
|
case "freedom":
|
|
if tag, ok := obMap["tag"].(string); ok {
|
|
freedoms = append(freedoms, tag)
|
|
}
|
|
case "blackhole":
|
|
if tag, ok := obMap["tag"].(string); ok {
|
|
blackholes = append(blackholes, tag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(freedoms) == 0 {
|
|
freedoms = []string{"direct"}
|
|
}
|
|
if len(blackholes) == 0 {
|
|
blackholes = []string{"blocked"}
|
|
}
|
|
|
|
var entries []LogEntry
|
|
for _, line := range rawLogs {
|
|
var entry LogEntry
|
|
parts := strings.Fields(line)
|
|
|
|
for i, part := range parts {
|
|
if i == 0 && len(parts) > 1 {
|
|
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
|
|
if err == nil {
|
|
entry.DateTime = dateTime.UTC()
|
|
}
|
|
}
|
|
|
|
if part == "from" && i+1 < len(parts) {
|
|
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
|
|
} else if part == "accepted" && i+1 < len(parts) {
|
|
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
|
|
} else if strings.HasPrefix(part, "[") {
|
|
entry.Inbound = part[1:]
|
|
} else if strings.HasSuffix(part, "]") {
|
|
entry.Outbound = part[:len(part)-1]
|
|
} else if part == "email:" && i+1 < len(parts) {
|
|
entry.Email = parts[i+1]
|
|
}
|
|
}
|
|
|
|
// Determine event type
|
|
logEntryContains := func(line string, suffixes []string) bool {
|
|
for _, sfx := range suffixes {
|
|
if strings.Contains(line, sfx+"]") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if logEntryContains(line, freedoms) {
|
|
if showDirect == "false" {
|
|
continue
|
|
}
|
|
entry.Event = Direct
|
|
} else if logEntryContains(line, blackholes) {
|
|
if showBlocked == "false" {
|
|
continue
|
|
}
|
|
entry.Event = Blocked
|
|
} else {
|
|
if showProxy == "false" {
|
|
continue
|
|
}
|
|
entry.Event = Proxied
|
|
}
|
|
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
jsonObj(c, entries, nil)
|
|
}
|
|
|
|
// checkNodeConnection checks if a node is reachable (health check without API key).
|
|
// This is used during node registration to verify connectivity before registration.
|
|
func (a *NodeController) checkNodeConnection(c *gin.Context) {
|
|
type CheckConnectionRequest struct {
|
|
Address string `json:"address" form:"address" binding:"required"`
|
|
}
|
|
|
|
var req CheckConnectionRequest
|
|
// HttpUtil.post sends data as form-urlencoded (see axios-init.js)
|
|
// So we use ShouldBind which handles both form and JSON
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
jsonMsg(c, "Invalid request: "+err.Error(), err)
|
|
return
|
|
}
|
|
|
|
if req.Address == "" {
|
|
jsonMsg(c, "Address is required", nil)
|
|
return
|
|
}
|
|
|
|
// Create a temporary node object for health check
|
|
tempNode := &model.Node{
|
|
Address: req.Address,
|
|
}
|
|
|
|
// Check node health (this only uses /health endpoint, no API key required)
|
|
status, responseTime, err := a.nodeService.CheckNodeStatus(tempNode)
|
|
if err != nil {
|
|
jsonMsg(c, "Node is not reachable: "+err.Error(), err)
|
|
return
|
|
}
|
|
|
|
if status != "online" {
|
|
jsonMsg(c, "Node is not online (status: "+status+")", nil)
|
|
return
|
|
}
|
|
|
|
// Return response time along with success message
|
|
jsonMsgObj(c, fmt.Sprintf("Node is reachable (response time: %d ms)", responseTime), map[string]interface{}{
|
|
"responseTime": responseTime,
|
|
}, nil)
|
|
}
|
|
|
|
// broadcastNodesUpdate broadcasts the current nodes list to all WebSocket clients
|
|
func (a *NodeController) broadcastNodesUpdate() {
|
|
// Get all nodes with their inbounds
|
|
nodes, err := a.nodeService.GetAllNodes()
|
|
if err != nil {
|
|
logger.Warningf("Failed to get nodes for WebSocket broadcast: %v", err)
|
|
return
|
|
}
|
|
|
|
// Enrich nodes with assigned inbounds information
|
|
type NodeWithInbounds struct {
|
|
*model.Node
|
|
Inbounds []*model.Inbound `json:"inbounds,omitempty"`
|
|
}
|
|
|
|
result := make([]NodeWithInbounds, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
inbounds, _ := a.nodeService.GetInboundsForNode(node.Id)
|
|
result = append(result, NodeWithInbounds{
|
|
Node: node,
|
|
Inbounds: inbounds,
|
|
})
|
|
}
|
|
|
|
// Broadcast via WebSocket
|
|
websocket.BroadcastNodes(result)
|
|
}
|