2026-01-05 21:12:53 +00:00
|
|
|
// Package api provides REST API endpoints for the node service.
|
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
2026-01-12 02:01:31 +00:00
|
|
|
"strconv"
|
2026-01-05 21:12:53 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
2026-01-12 02:01:31 +00:00
|
|
|
nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config"
|
|
|
|
|
nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs"
|
2026-01-05 21:12:53 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Server provides REST API for managing the node.
|
|
|
|
|
type Server struct {
|
|
|
|
|
port int
|
|
|
|
|
apiKey string
|
|
|
|
|
xrayManager *xray.Manager
|
|
|
|
|
httpServer *http.Server
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewServer creates a new API server instance.
|
|
|
|
|
func NewServer(port int, apiKey string, xrayManager *xray.Manager) *Server {
|
|
|
|
|
return &Server{
|
|
|
|
|
port: port,
|
|
|
|
|
apiKey: apiKey,
|
|
|
|
|
xrayManager: xrayManager,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start starts the HTTP server.
|
|
|
|
|
func (s *Server) Start() error {
|
|
|
|
|
gin.SetMode(gin.ReleaseMode)
|
|
|
|
|
router := gin.New()
|
|
|
|
|
router.Use(gin.Recovery())
|
|
|
|
|
router.Use(s.authMiddleware())
|
|
|
|
|
|
|
|
|
|
// Health check endpoint (no auth required)
|
|
|
|
|
router.GET("/health", s.health)
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// Registration endpoint (no auth required, used for initial setup)
|
|
|
|
|
router.POST("/api/v1/register", s.register)
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
// API endpoints (require auth)
|
|
|
|
|
api := router.Group("/api/v1")
|
|
|
|
|
{
|
|
|
|
|
api.POST("/apply-config", s.applyConfig)
|
|
|
|
|
api.POST("/reload", s.reload)
|
2026-01-06 00:08:00 +00:00
|
|
|
api.POST("/force-reload", s.forceReload)
|
2026-01-05 21:12:53 +00:00
|
|
|
api.GET("/status", s.status)
|
2026-01-05 23:27:12 +00:00
|
|
|
api.GET("/stats", s.stats)
|
2026-01-12 02:01:31 +00:00
|
|
|
api.GET("/logs", s.getLogs)
|
|
|
|
|
api.GET("/service-logs", s.getServiceLogs)
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.httpServer = &http.Server{
|
|
|
|
|
Addr: fmt.Sprintf(":%d", s.port),
|
|
|
|
|
Handler: router,
|
|
|
|
|
ReadTimeout: 10 * time.Second,
|
|
|
|
|
WriteTimeout: 10 * time.Second,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Infof("API server listening on port %d", s.port)
|
|
|
|
|
return s.httpServer.ListenAndServe()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop stops the HTTP server.
|
|
|
|
|
func (s *Server) Stop() error {
|
|
|
|
|
if s.httpServer == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return s.httpServer.Close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// authMiddleware validates API key from Authorization header.
|
|
|
|
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
|
|
|
|
return func(c *gin.Context) {
|
2026-01-12 02:01:31 +00:00
|
|
|
// Skip auth for health and registration endpoints
|
|
|
|
|
if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" {
|
2026-01-05 21:12:53 +00:00
|
|
|
c.Next()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authHeader := c.GetHeader("Authorization")
|
|
|
|
|
if authHeader == "" {
|
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
|
|
|
|
|
c.Abort()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Support both "Bearer <key>" and direct key
|
|
|
|
|
apiKey := authHeader
|
|
|
|
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
|
|
|
|
apiKey = authHeader[7:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if apiKey != s.apiKey {
|
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
|
|
|
|
c.Abort()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.Next()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// health returns the health status of the node.
|
|
|
|
|
func (s *Server) health(c *gin.Context) {
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"service": "3x-ui-node",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// applyConfig applies a new XRAY configuration.
|
|
|
|
|
func (s *Server) applyConfig(c *gin.Context) {
|
|
|
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// Try to parse as JSON with optional panelUrl field
|
|
|
|
|
var requestData struct {
|
|
|
|
|
Config json.RawMessage `json:"config"`
|
|
|
|
|
PanelURL string `json:"panelUrl,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First try to parse as new format with panelUrl
|
|
|
|
|
if err := json.Unmarshal(body, &requestData); err == nil && requestData.PanelURL != "" {
|
|
|
|
|
// New format: { "config": {...}, "panelUrl": "http://..." }
|
|
|
|
|
body = requestData.Config
|
|
|
|
|
// Set panel URL for log pusher
|
|
|
|
|
nodeLogs.SetPanelURL(requestData.PanelURL)
|
|
|
|
|
} else {
|
|
|
|
|
// Old format: just JSON config, validate it
|
|
|
|
|
var configJSON json.RawMessage
|
|
|
|
|
if err := json.Unmarshal(body, &configJSON); err != nil {
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.xrayManager.ApplyConfig(body); err != nil {
|
|
|
|
|
logger.Errorf("Failed to apply config: %v", err)
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Configuration applied successfully"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// reload reloads XRAY configuration.
|
|
|
|
|
func (s *Server) reload(c *gin.Context) {
|
|
|
|
|
if err := s.xrayManager.Reload(); err != nil {
|
|
|
|
|
logger.Errorf("Failed to reload: %v", err)
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "XRAY reloaded successfully"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 00:08:00 +00:00
|
|
|
// forceReload forcefully reloads XRAY even if it's hung or not running.
|
|
|
|
|
func (s *Server) forceReload(c *gin.Context) {
|
|
|
|
|
if err := s.xrayManager.ForceReload(); err != nil {
|
|
|
|
|
logger.Errorf("Failed to force reload: %v", err)
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "XRAY force reloaded successfully"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
// status returns the current status of XRAY.
|
|
|
|
|
func (s *Server) status(c *gin.Context) {
|
|
|
|
|
status := s.xrayManager.GetStatus()
|
|
|
|
|
c.JSON(http.StatusOK, status)
|
|
|
|
|
}
|
2026-01-05 23:27:12 +00:00
|
|
|
|
|
|
|
|
// stats returns traffic and online clients statistics from XRAY.
|
|
|
|
|
func (s *Server) stats(c *gin.Context) {
|
|
|
|
|
// Get reset parameter (default: false)
|
|
|
|
|
reset := c.DefaultQuery("reset", "false") == "true"
|
|
|
|
|
|
|
|
|
|
stats, err := s.xrayManager.GetStats(reset)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Errorf("Failed to get stats: %v", err)
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
|
|
|
}
|
2026-01-12 02:01:31 +00:00
|
|
|
|
|
|
|
|
// getLogs returns XRAY access logs from the node.
|
|
|
|
|
func (s *Server) getLogs(c *gin.Context) {
|
|
|
|
|
// Get query parameters
|
|
|
|
|
countStr := c.DefaultQuery("count", "100")
|
|
|
|
|
filter := c.DefaultQuery("filter", "")
|
|
|
|
|
|
|
|
|
|
count, err := strconv.Atoi(countStr)
|
|
|
|
|
if err != nil || count < 1 || count > 10000 {
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logs, err := s.xrayManager.GetLogs(count, filter)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Errorf("Failed to get logs: %v", err)
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getServiceLogs returns service application logs from the node (node service logs and XRAY core logs).
|
|
|
|
|
func (s *Server) getServiceLogs(c *gin.Context) {
|
|
|
|
|
// Get query parameters
|
|
|
|
|
countStr := c.DefaultQuery("count", "100")
|
|
|
|
|
level := c.DefaultQuery("level", "debug")
|
|
|
|
|
|
|
|
|
|
count, err := strconv.Atoi(countStr)
|
|
|
|
|
if err != nil || count < 1 || count > 10000 {
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get logs from logger buffer
|
|
|
|
|
logs := logger.GetLogs(count, level)
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// register handles node registration from the panel.
|
|
|
|
|
// This endpoint receives an API key from the panel and saves it persistently.
|
|
|
|
|
// No authentication required - this is the initial setup step.
|
|
|
|
|
func (s *Server) register(c *gin.Context) {
|
|
|
|
|
type RegisterRequest struct {
|
|
|
|
|
ApiKey string `json:"apiKey" binding:"required"` // API key generated by panel
|
|
|
|
|
PanelURL string `json:"panelUrl,omitempty"` // Panel URL (optional)
|
|
|
|
|
NodeAddress string `json:"nodeAddress,omitempty"` // Node address (optional)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req RegisterRequest
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if node is already registered
|
|
|
|
|
existingConfig := nodeConfig.GetConfig()
|
|
|
|
|
if existingConfig.ApiKey != "" {
|
|
|
|
|
logger.Warningf("Node is already registered. Rejecting registration attempt to prevent overwriting existing API key")
|
|
|
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
|
|
|
"error": "Node is already registered. API key cannot be overwritten",
|
|
|
|
|
"message": "This node has already been registered. If you need to re-register, please remove the node-config.json file first",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save API key to config file (only if not already registered)
|
|
|
|
|
if err := nodeConfig.SetApiKey(req.ApiKey, false); err != nil {
|
|
|
|
|
logger.Errorf("Failed to save API key: %v", err)
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save API key: " + err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update API key in server (for immediate use)
|
|
|
|
|
s.apiKey = req.ApiKey
|
|
|
|
|
|
|
|
|
|
// Save panel URL if provided
|
|
|
|
|
if req.PanelURL != "" {
|
|
|
|
|
if err := nodeConfig.SetPanelURL(req.PanelURL); err != nil {
|
|
|
|
|
logger.Warningf("Failed to save panel URL: %v", err)
|
|
|
|
|
} else {
|
|
|
|
|
// Update log pusher with new panel URL and API key
|
|
|
|
|
nodeLogs.SetPanelURL(req.PanelURL)
|
|
|
|
|
nodeLogs.UpdateApiKey(req.ApiKey) // Update API key in log pusher
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Even if panel URL is not provided, update API key in log pusher
|
|
|
|
|
nodeLogs.UpdateApiKey(req.ApiKey)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save node address if provided
|
|
|
|
|
if req.NodeAddress != "" {
|
|
|
|
|
if err := nodeConfig.SetNodeAddress(req.NodeAddress); err != nil {
|
|
|
|
|
logger.Warningf("Failed to save node address: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Infof("Node registered successfully with API key (length: %d)", len(req.ApiKey))
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
"message": "Node registered successfully",
|
|
|
|
|
"apiKey": req.ApiKey, // Return API key for confirmation
|
|
|
|
|
})
|
|
|
|
|
}
|