3x-ui/web/controller/api.go

214 lines
6.6 KiB
Go

package controller
import (
"fmt"
"net/http"
"regexp"
"strings"
"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/session"
"github.com/gin-gonic/gin"
)
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
type APIController struct {
BaseController
inboundController *InboundController
serverController *ServerController
Tgbot service.Tgbot
}
// NewAPIController creates a new APIController instance and initializes its routes.
func NewAPIController(g *gin.RouterGroup) *APIController {
a := &APIController{}
a.initRouter(g)
return a
}
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users
func (a *APIController) checkAPIAuth(c *gin.Context) {
if !session.IsLogin(c) {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Next()
}
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) {
// Node push-logs endpoint (no session auth, uses API key)
// Register in separate group without session auth middleware
nodeAPI := g.Group("/panel/api/node")
nodeAPI.POST("/push-logs", a.pushNodeLogs)
// Main API group with session auth
api := g.Group("/panel/api")
api.Use(a.checkAPIAuth)
// Inbounds API
inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds)
// Server API
server := api.Group("/server")
a.serverController = NewServerController(server)
// Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot)
}
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
func (a *APIController) BackuptoTgbot(c *gin.Context) {
a.Tgbot.SendBackupToAdmins()
}
// extractPort extracts port number from URL address (e.g., "http://192.168.0.7:8080" -> "8080")
func extractPort(address string) string {
re := regexp.MustCompile(`:(\d+)(?:/|$)`)
matches := re.FindStringSubmatch(address)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// pushNodeLogs receives logs from a node in real-time and adds them to the panel log buffer.
// This endpoint is called by nodes when new logs are generated.
// It uses API key authentication instead of session authentication.
func (a *APIController) pushNodeLogs(c *gin.Context) {
type PushLogRequest struct {
ApiKey string `json:"apiKey" binding:"required"` // Node API key for authentication
NodeAddress string `json:"nodeAddress,omitempty"` // Node's own address for identification (optional, used when multiple nodes share API key)
Logs []string `json:"logs" binding:"required"` // Array of log lines in format "timestamp level - message"
}
var req PushLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Find node by API key and optionally by address
nodeService := service.NodeService{}
nodes, err := nodeService.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get nodes"})
return
}
var node *model.Node
var matchedByKey []*model.Node // Track nodes with matching API key
for _, n := range nodes {
if n.ApiKey == req.ApiKey {
matchedByKey = append(matchedByKey, n)
// If nodeAddress is provided, match by both API key and address
if req.NodeAddress != "" {
// Normalize addresses for comparison (remove trailing slashes, etc.)
nodeAddr := strings.TrimSuffix(strings.TrimSpace(n.Address), "/")
reqAddr := strings.TrimSuffix(strings.TrimSpace(req.NodeAddress), "/")
// Extract port from both addresses for comparison
// This handles cases where node uses localhost but panel has external IP
nodePort := extractPort(nodeAddr)
reqPort := extractPort(reqAddr)
// Match by exact address or by port (if addresses don't match exactly)
// This allows nodes to use localhost while panel has external IP
if nodeAddr == reqAddr || (nodePort != "" && nodePort == reqPort) {
node = n
break
}
} else {
// If no address provided, use first match (backward compatibility)
node = n
break
}
}
}
if node == nil {
// Enhanced logging for debugging
if len(matchedByKey) > 0 {
logger.Debugf("Failed to find node: API key matches %d node(s), but address mismatch. Request address: '%s', Request port: '%s'. Matched nodes: %v",
len(matchedByKey), req.NodeAddress, extractPort(req.NodeAddress),
func() []string {
var addrs []string
for _, n := range matchedByKey {
addrs = append(addrs, fmt.Sprintf("%s (port: %s)", n.Address, extractPort(n.Address)))
}
return addrs
}())
} else {
logger.Debugf("Failed to find node: No node found with API key (received %d logs, key length: %d, key prefix: %s). Total nodes in DB: %d",
len(req.Logs), len(req.ApiKey),
func() string {
if len(req.ApiKey) > 4 {
return req.ApiKey[:4] + "..."
}
return req.ApiKey
}(), len(nodes))
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
return
}
// Log which node is sending logs (for debugging)
logger.Debugf("Received %d logs from node: %s (ID: %d, Address: %s, API key length: %d)",
len(req.Logs), node.Name, node.Id, node.Address, len(req.ApiKey))
// Process and add logs to panel buffer
for _, logLine := range req.Logs {
if logLine == "" {
continue
}
// Parse log line: format is "timestamp level - message"
var level string
var message string
if idx := strings.Index(logLine, " - "); idx != -1 {
parts := strings.SplitN(logLine, " - ", 2)
if len(parts) == 2 {
levelPart := strings.TrimSpace(parts[0])
levelFields := strings.Fields(levelPart)
if len(levelFields) >= 2 {
level = strings.ToUpper(levelFields[len(levelFields)-1])
message = parts[1]
} else {
level = "INFO"
message = parts[1]
}
} else {
level = "INFO"
message = logLine
}
} else {
level = "INFO"
message = logLine
}
// Add log to panel buffer with node prefix
formattedMessage := fmt.Sprintf("[Node: %s] %s", node.Name, message)
switch level {
case "DEBUG":
logger.Debugf("%s", formattedMessage)
case "WARNING":
logger.Warningf("%s", formattedMessage)
case "ERROR":
logger.Errorf("%s", formattedMessage)
case "NOTICE":
logger.Noticef("%s", formattedMessage)
default:
logger.Infof("%s", formattedMessage)
}
}
c.JSON(http.StatusOK, gin.H{"message": "Logs received"})
}