mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
edit registration steps nodes (auto setup api-key), edit loggers
This commit is contained in:
parent
a196dcddb0
commit
9263402370
24 changed files with 2244 additions and 97 deletions
|
|
@ -167,9 +167,10 @@ type Node struct {
|
||||||
Name string `json:"name" form:"name"` // Node name/identifier
|
Name string `json:"name" form:"name"` // Node name/identifier
|
||||||
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...")
|
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...")
|
||||||
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
|
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
|
||||||
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
|
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
|
||||||
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
|
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
|
||||||
UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls
|
ResponseTime int64 `json:"responseTime" gorm:"default:0"` // Response time in milliseconds (0 = not measured or error)
|
||||||
|
UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls
|
||||||
CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA)
|
CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA)
|
||||||
KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA)
|
KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA)
|
||||||
InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended)
|
InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
|
@ -209,6 +210,27 @@ func addToBuffer(level string, newLog string) {
|
||||||
level: logLevel,
|
level: logLevel,
|
||||||
log: newLog,
|
log: newLog,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If running on node, push log to panel in real-time
|
||||||
|
// Check if we're in node mode by checking for NODE_API_KEY environment variable
|
||||||
|
if os.Getenv("NODE_API_KEY") != "" {
|
||||||
|
// Format log line as "timestamp level - message" for panel
|
||||||
|
logLine := fmt.Sprintf("%s %s - %s", t.Format(timeFormat), strings.ToUpper(level), newLog)
|
||||||
|
// Use build tag or lazy initialization to avoid circular dependency
|
||||||
|
// For now, we'll use a simple check - if node/logs package is available
|
||||||
|
pushLogToPanel(logLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushLogToPanel pushes a log line to the panel (called from node mode only).
|
||||||
|
// This function will be implemented in node package to avoid circular dependency.
|
||||||
|
var pushLogToPanel = func(logLine string) {
|
||||||
|
// Default: no-op, will be overridden by node package if available
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogPusher sets the function to push logs to panel (called from node package).
|
||||||
|
func SetLogPusher(pusher func(string)) {
|
||||||
|
pushLogToPanel = pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogs retrieves up to c log entries from the buffer that are at or below the specified level.
|
// GetLogs retrieves up to c log entries from the buffer that are at or below the specified level.
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config"
|
||||||
|
nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs"
|
||||||
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -40,6 +43,9 @@ func (s *Server) Start() error {
|
||||||
// Health check endpoint (no auth required)
|
// Health check endpoint (no auth required)
|
||||||
router.GET("/health", s.health)
|
router.GET("/health", s.health)
|
||||||
|
|
||||||
|
// Registration endpoint (no auth required, used for initial setup)
|
||||||
|
router.POST("/api/v1/register", s.register)
|
||||||
|
|
||||||
// API endpoints (require auth)
|
// API endpoints (require auth)
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
{
|
{
|
||||||
|
|
@ -48,6 +54,8 @@ func (s *Server) Start() error {
|
||||||
api.POST("/force-reload", s.forceReload)
|
api.POST("/force-reload", s.forceReload)
|
||||||
api.GET("/status", s.status)
|
api.GET("/status", s.status)
|
||||||
api.GET("/stats", s.stats)
|
api.GET("/stats", s.stats)
|
||||||
|
api.GET("/logs", s.getLogs)
|
||||||
|
api.GET("/service-logs", s.getServiceLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.httpServer = &http.Server{
|
s.httpServer = &http.Server{
|
||||||
|
|
@ -72,8 +80,8 @@ func (s *Server) Stop() error {
|
||||||
// authMiddleware validates API key from Authorization header.
|
// authMiddleware validates API key from Authorization header.
|
||||||
func (s *Server) authMiddleware() gin.HandlerFunc {
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Skip auth for health endpoint
|
// Skip auth for health and registration endpoints
|
||||||
if c.Request.URL.Path == "/health" {
|
if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -117,11 +125,25 @@ func (s *Server) applyConfig(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate JSON
|
// Try to parse as JSON with optional panelUrl field
|
||||||
var configJSON json.RawMessage
|
var requestData struct {
|
||||||
if err := json.Unmarshal(body, &configJSON); err != nil {
|
Config json.RawMessage `json:"config"`
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
|
PanelURL string `json:"panelUrl,omitempty"`
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.xrayManager.ApplyConfig(body); err != nil {
|
if err := s.xrayManager.ApplyConfig(body); err != nil {
|
||||||
|
|
@ -175,3 +197,107 @@ func (s *Server) stats(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
156
node/config/config.go
Normal file
156
node/config/config.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
// Package config provides node configuration management, including API key persistence.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeConfig represents the node's configuration stored on disk.
|
||||||
|
type NodeConfig struct {
|
||||||
|
ApiKey string `json:"apiKey"` // API key for authentication with panel
|
||||||
|
PanelURL string `json:"panelUrl"` // Panel URL (optional, can be set via env var)
|
||||||
|
NodeAddress string `json:"nodeAddress"` // Node's own address (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
config *NodeConfig
|
||||||
|
configMu sync.RWMutex
|
||||||
|
configPath string
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitConfig initializes the configuration system and loads existing config if available.
|
||||||
|
// configDir is the directory where config file will be stored (e.g., "bin", "/app/bin").
|
||||||
|
func InitConfig(configDir string) error {
|
||||||
|
configMu.Lock()
|
||||||
|
defer configMu.Unlock()
|
||||||
|
|
||||||
|
// Determine config file path
|
||||||
|
if configDir == "" {
|
||||||
|
// Try common paths
|
||||||
|
possibleDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"}
|
||||||
|
for _, dir := range possibleDirs {
|
||||||
|
if _, err := os.Stat(dir); err == nil {
|
||||||
|
configDir = dir
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if configDir == "" {
|
||||||
|
configDir = "." // Fallback to current directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath = filepath.Join(configDir, "node-config.json")
|
||||||
|
|
||||||
|
// Try to load existing config
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
var loadedConfig NodeConfig
|
||||||
|
if err := json.Unmarshal(data, &loadedConfig); err == nil {
|
||||||
|
config = &loadedConfig
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// If file exists but is invalid, we'll create a new one
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty config if file doesn't exist
|
||||||
|
config = &NodeConfig{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the current node configuration.
|
||||||
|
func GetConfig() *NodeConfig {
|
||||||
|
configMu.RLock()
|
||||||
|
defer configMu.RUnlock()
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
return &NodeConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a copy to prevent external modifications
|
||||||
|
return &NodeConfig{
|
||||||
|
ApiKey: config.ApiKey,
|
||||||
|
PanelURL: config.PanelURL,
|
||||||
|
NodeAddress: config.NodeAddress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetApiKey sets the API key and saves it to disk.
|
||||||
|
// If an API key already exists, it will not be overwritten unless force is true.
|
||||||
|
func SetApiKey(apiKey string, force bool) error {
|
||||||
|
configMu.Lock()
|
||||||
|
defer configMu.Unlock()
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
config = &NodeConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if API key already exists
|
||||||
|
if config.ApiKey != "" && !force {
|
||||||
|
return fmt.Errorf("API key already exists. Use force=true to overwrite")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.ApiKey = apiKey
|
||||||
|
return saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPanelURL sets the panel URL and saves it to disk.
|
||||||
|
func SetPanelURL(url string) error {
|
||||||
|
configMu.Lock()
|
||||||
|
defer configMu.Unlock()
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
config = &NodeConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.PanelURL = url
|
||||||
|
return saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNodeAddress sets the node address and saves it to disk.
|
||||||
|
func SetNodeAddress(address string) error {
|
||||||
|
configMu.Lock()
|
||||||
|
defer configMu.Unlock()
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
config = &NodeConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.NodeAddress = address
|
||||||
|
return saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveConfig saves the current configuration to disk.
|
||||||
|
func saveConfig() error {
|
||||||
|
if configPath == "" {
|
||||||
|
return fmt.Errorf("config path not initialized, call InitConfig first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal config to JSON
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to file with proper permissions (readable/writable by owner only)
|
||||||
|
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigPath returns the path to the config file.
|
||||||
|
func GetConfigPath() string {
|
||||||
|
configMu.RLock()
|
||||||
|
defer configMu.RUnlock()
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,8 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||||
- NODE_API_KEY=test-key
|
#- NODE_API_KEY=test-key
|
||||||
|
- PANEL_URL=http://192.168.0.7:2054
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "44000:44000"
|
- "44000:44000"
|
||||||
|
|
@ -26,7 +27,8 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||||
- NODE_API_KEY=test-key
|
#- NODE_API_KEY=test-key1
|
||||||
|
- PANEL_URL=http://192.168.0.7:2054
|
||||||
ports:
|
ports:
|
||||||
- "8081:8080"
|
- "8081:8080"
|
||||||
- "44001:44001"
|
- "44001:44001"
|
||||||
|
|
@ -45,7 +47,8 @@ services:
|
||||||
container_name: 3x-ui-node3
|
container_name: 3x-ui-node3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_API_KEY=test-key
|
#- NODE_API_KEY=test-key
|
||||||
|
- PANEL_URL=http://192.168.0.7:2054
|
||||||
ports:
|
ports:
|
||||||
- "8082:8080"
|
- "8082:8080"
|
||||||
- "44002:44002"
|
- "44002:44002"
|
||||||
|
|
|
||||||
346
node/logs/pusher.go
Normal file
346
node/logs/pusher.go
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
// Package logs provides log pushing functionality for sending logs from node to panel in real-time.
|
||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogPusher sends logs to the panel in real-time.
|
||||||
|
type LogPusher struct {
|
||||||
|
panelURL string
|
||||||
|
apiKey string
|
||||||
|
nodeAddress string // Node's own address for identification
|
||||||
|
logBuffer []string
|
||||||
|
bufferMu sync.Mutex
|
||||||
|
client *http.Client
|
||||||
|
enabled bool
|
||||||
|
lastPush time.Time
|
||||||
|
pushTicker *time.Ticker
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pusher *LogPusher
|
||||||
|
pusherOnce sync.Once
|
||||||
|
pusherMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitLogPusher initializes the log pusher if panel URL and API key are configured.
|
||||||
|
// nodeAddress is the address of this node (e.g., "http://192.168.0.7:8080") for identification.
|
||||||
|
func InitLogPusher(nodeAddress string) {
|
||||||
|
pusherOnce.Do(func() {
|
||||||
|
// Try to get API key from (in order of priority):
|
||||||
|
// 1. Environment variable
|
||||||
|
// 2. Saved config file
|
||||||
|
apiKey := os.Getenv("NODE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
// Try to load from saved config
|
||||||
|
cfg := getNodeConfig()
|
||||||
|
if cfg != nil && cfg.ApiKey != "" {
|
||||||
|
apiKey = cfg.ApiKey
|
||||||
|
logger.Debug("Using API key from saved configuration for log pusher")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "" {
|
||||||
|
logger.Debug("Log pusher disabled: no API key found (will be enabled after registration)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get panel URL from environment variable first, then from saved config
|
||||||
|
panelURL := os.Getenv("PANEL_URL")
|
||||||
|
if panelURL == "" {
|
||||||
|
cfg := getNodeConfig()
|
||||||
|
if cfg != nil && cfg.PanelURL != "" {
|
||||||
|
panelURL = cfg.PanelURL
|
||||||
|
logger.Debug("Using panel URL from saved configuration for log pusher")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pusher = &LogPusher{
|
||||||
|
panelURL: panelURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
nodeAddress: nodeAddress,
|
||||||
|
logBuffer: make([]string, 0, 10),
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
enabled: panelURL != "", // Enable only if panel URL is set
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if pusher.enabled {
|
||||||
|
// Start periodic push (every 2 seconds or when buffer is full)
|
||||||
|
pusher.pushTicker = time.NewTicker(2 * time.Second)
|
||||||
|
go pusher.run()
|
||||||
|
logger.Debugf("Log pusher initialized: sending logs to %s", panelURL)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Log pusher initialized but disabled: waiting for panel URL")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeConfigData represents the node configuration structure.
|
||||||
|
type nodeConfigData struct {
|
||||||
|
ApiKey string `json:"apiKey"`
|
||||||
|
PanelURL string `json:"panelUrl"`
|
||||||
|
NodeAddress string `json:"nodeAddress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNodeConfig is a helper to get node config without circular dependency.
|
||||||
|
// It reads the config file directly to avoid importing the config package.
|
||||||
|
func getNodeConfig() *nodeConfigData {
|
||||||
|
configPaths := []string{"bin/node-config.json", "config/node-config.json", "./node-config.json", "/app/bin/node-config.json", "/app/config/node-config.json"}
|
||||||
|
|
||||||
|
for _, path := range configPaths {
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
var config nodeConfigData
|
||||||
|
if err := json.Unmarshal(data, &config); err == nil {
|
||||||
|
return &config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPanelURL sets the panel URL and enables the log pusher.
|
||||||
|
// PANEL_URL from environment variable has priority and won't be overwritten.
|
||||||
|
func SetPanelURL(url string) {
|
||||||
|
pusherMu.Lock()
|
||||||
|
defer pusherMu.Unlock()
|
||||||
|
|
||||||
|
// Check if PANEL_URL is set in environment - it has priority
|
||||||
|
envPanelURL := os.Getenv("PANEL_URL")
|
||||||
|
if envPanelURL != "" {
|
||||||
|
// Environment variable has priority, ignore URL from config
|
||||||
|
if pusher != nil && pusher.panelURL == envPanelURL {
|
||||||
|
// Already set from env, don't update
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Use environment variable instead
|
||||||
|
url = envPanelURL
|
||||||
|
logger.Debugf("Using PANEL_URL from environment: %s (ignoring config URL)", envPanelURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pusher == nil {
|
||||||
|
// Initialize if not already initialized
|
||||||
|
apiKey := os.Getenv("NODE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
// Try to load from saved config
|
||||||
|
cfg := getNodeConfig()
|
||||||
|
if cfg != nil && cfg.ApiKey != "" {
|
||||||
|
apiKey = cfg.ApiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "" {
|
||||||
|
logger.Debug("Cannot set panel URL: no API key found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get node address from environment if not provided
|
||||||
|
nodeAddress := os.Getenv("NODE_ADDRESS")
|
||||||
|
if nodeAddress == "" {
|
||||||
|
cfg := getNodeConfig()
|
||||||
|
if cfg != nil && cfg.NodeAddress != "" {
|
||||||
|
nodeAddress = cfg.NodeAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pusher = &LogPusher{
|
||||||
|
apiKey: apiKey,
|
||||||
|
nodeAddress: nodeAddress,
|
||||||
|
logBuffer: make([]string, 0, 10),
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if url == "" {
|
||||||
|
logger.Debug("Panel URL cleared, disabling log pusher")
|
||||||
|
pusher.enabled = false
|
||||||
|
if pusher.pushTicker != nil {
|
||||||
|
pusher.pushTicker.Stop()
|
||||||
|
pusher.pushTicker = nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wasEnabled := pusher.enabled
|
||||||
|
pusher.panelURL = url
|
||||||
|
pusher.enabled = true
|
||||||
|
|
||||||
|
if !wasEnabled && pusher.pushTicker == nil {
|
||||||
|
// Start periodic push if it wasn't running
|
||||||
|
pusher.pushTicker = time.NewTicker(2 * time.Second)
|
||||||
|
go pusher.run()
|
||||||
|
logger.Debugf("Log pusher enabled: sending logs to %s", url)
|
||||||
|
} else if wasEnabled && pusher.panelURL != url {
|
||||||
|
logger.Debugf("Log pusher panel URL updated: %s", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateApiKey updates the API key in the log pusher.
|
||||||
|
// This is called after node registration to enable log pushing.
|
||||||
|
func UpdateApiKey(apiKey string) {
|
||||||
|
pusherMu.Lock()
|
||||||
|
defer pusherMu.Unlock()
|
||||||
|
|
||||||
|
if pusher == nil {
|
||||||
|
logger.Debug("Cannot update API key: log pusher not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pusher.apiKey = apiKey
|
||||||
|
logger.Debugf("Log pusher API key updated (length: %d)", len(apiKey))
|
||||||
|
|
||||||
|
// If pusher is enabled but wasn't running, start it
|
||||||
|
if pusher.enabled && pusher.pushTicker == nil && pusher.panelURL != "" {
|
||||||
|
pusher.pushTicker = time.NewTicker(2 * time.Second)
|
||||||
|
go pusher.run()
|
||||||
|
logger.Debugf("Log pusher started after API key update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushLog adds a log entry to the buffer for sending to panel.
|
||||||
|
func PushLog(logLine string) {
|
||||||
|
if pusher == nil || !pusher.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip logs that already contain node prefix to avoid infinite loop
|
||||||
|
// These are logs that came from panel and shouldn't be sent back
|
||||||
|
if strings.Contains(logLine, "[Node:") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip logs about log pushing itself to avoid infinite loop
|
||||||
|
if strings.Contains(logLine, "Logs pushed:") || strings.Contains(logLine, "Failed to push logs") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pusher.bufferMu.Lock()
|
||||||
|
defer pusher.bufferMu.Unlock()
|
||||||
|
|
||||||
|
pusher.logBuffer = append(pusher.logBuffer, logLine)
|
||||||
|
|
||||||
|
// If buffer is getting large, push immediately
|
||||||
|
if len(pusher.logBuffer) >= 10 {
|
||||||
|
go pusher.push()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run periodically pushes logs to panel.
|
||||||
|
func (lp *LogPusher) run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-lp.pushTicker.C:
|
||||||
|
lp.bufferMu.Lock()
|
||||||
|
if len(lp.logBuffer) > 0 {
|
||||||
|
logsToPush := make([]string, len(lp.logBuffer))
|
||||||
|
copy(logsToPush, lp.logBuffer)
|
||||||
|
lp.logBuffer = lp.logBuffer[:0]
|
||||||
|
lp.bufferMu.Unlock()
|
||||||
|
|
||||||
|
go lp.pushLogs(logsToPush)
|
||||||
|
} else {
|
||||||
|
lp.bufferMu.Unlock()
|
||||||
|
}
|
||||||
|
case <-lp.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push immediately pushes current buffer to panel.
|
||||||
|
func (lp *LogPusher) push() {
|
||||||
|
lp.bufferMu.Lock()
|
||||||
|
if len(lp.logBuffer) == 0 {
|
||||||
|
lp.bufferMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logsToPush := make([]string, len(lp.logBuffer))
|
||||||
|
copy(logsToPush, lp.logBuffer)
|
||||||
|
lp.logBuffer = lp.logBuffer[:0]
|
||||||
|
lp.bufferMu.Unlock()
|
||||||
|
|
||||||
|
lp.pushLogs(logsToPush)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushLogs sends logs to the panel.
|
||||||
|
func (lp *LogPusher) pushLogs(logs []string) {
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct panel URL
|
||||||
|
panelEndpoint := lp.panelURL
|
||||||
|
if panelEndpoint[len(panelEndpoint)-1] != '/' {
|
||||||
|
panelEndpoint += "/"
|
||||||
|
}
|
||||||
|
panelEndpoint += "panel/api/node/push-logs"
|
||||||
|
|
||||||
|
// Log push attempt (DEBUG level to avoid sending this log back to panel)
|
||||||
|
logger.Debugf("Logs pushed: %d log entries to %s", len(logs), panelEndpoint)
|
||||||
|
|
||||||
|
// Prepare request
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"apiKey": lp.apiKey,
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
|
// Add node address for identification (in case multiple nodes share the same API key)
|
||||||
|
if lp.nodeAddress != "" {
|
||||||
|
reqBody["nodeAddress"] = lp.nodeAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to marshal log push request to %s: %v", panelEndpoint, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", panelEndpoint, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create log push request to %s: %v", panelEndpoint, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := lp.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to push logs to panel at %s: %v (check if panel URL is correct and accessible)", panelEndpoint, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
logger.Errorf("Panel at %s returned non-OK status %d for log push: %s", panelEndpoint, resp.StatusCode, string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lp.lastPush = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the log pusher.
|
||||||
|
func Stop() {
|
||||||
|
if pusher != nil && pusher.pushTicker != nil {
|
||||||
|
pusher.pushTicker.Stop()
|
||||||
|
close(pusher.stopCh)
|
||||||
|
// Push remaining logs
|
||||||
|
pusher.push()
|
||||||
|
}
|
||||||
|
}
|
||||||
72
node/main.go
72
node/main.go
|
|
@ -4,6 +4,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
@ -11,27 +12,88 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/node/api"
|
"github.com/mhsanaei/3x-ui/v2/node/api"
|
||||||
|
nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config"
|
||||||
|
nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs"
|
||||||
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
"github.com/mhsanaei/3x-ui/v2/node/xray"
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var port int
|
var port int
|
||||||
var apiKey string
|
var apiKey string
|
||||||
flag.IntVar(&port, "port", 8080, "API server port")
|
flag.IntVar(&port, "port", 8080, "API server port")
|
||||||
flag.StringVar(&apiKey, "api-key", "", "API key for authentication (required)")
|
flag.StringVar(&apiKey, "api-key", "", "API key for authentication (optional, can be set via registration)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Check environment variable if flag is not provided
|
logger.InitLogger(logging.INFO)
|
||||||
|
|
||||||
|
// Initialize node configuration system
|
||||||
|
// Try to find config directory (same as XRAY config)
|
||||||
|
configDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"}
|
||||||
|
var configDir string
|
||||||
|
for _, dir := range configDirs {
|
||||||
|
if _, err := os.Stat(dir); err == nil {
|
||||||
|
configDir = dir
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if configDir == "" {
|
||||||
|
configDir = "." // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nodeConfig.InitConfig(configDir); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize node config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API key from (in order of priority):
|
||||||
|
// 1. Command line flag
|
||||||
|
// 2. Environment variable (for backward compatibility)
|
||||||
|
// 3. Saved config file (from registration)
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
apiKey = os.Getenv("NODE_API_KEY")
|
apiKey = os.Getenv("NODE_API_KEY")
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
log.Fatal("API key is required. Set NODE_API_KEY environment variable or use -api-key flag")
|
// Try to load from saved config
|
||||||
|
savedConfig := nodeConfig.GetConfig()
|
||||||
|
if savedConfig.ApiKey != "" {
|
||||||
|
apiKey = savedConfig.ApiKey
|
||||||
|
log.Printf("Using API key from saved configuration")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InitLogger(logging.INFO)
|
// If still no API key, node can start but will need registration
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Printf("WARNING: No API key found. Node will need to be registered via /api/v1/register endpoint")
|
||||||
|
log.Printf("You can set NODE_API_KEY environment variable or use -api-key flag for immediate use")
|
||||||
|
// Use a temporary key that will be replaced during registration
|
||||||
|
apiKey = "temp-unregistered"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize log pusher if panel URL is configured
|
||||||
|
// Get node address from saved config or environment variable
|
||||||
|
savedConfig := nodeConfig.GetConfig()
|
||||||
|
nodeAddress := savedConfig.NodeAddress
|
||||||
|
if nodeAddress == "" {
|
||||||
|
nodeAddress = os.Getenv("NODE_ADDRESS")
|
||||||
|
}
|
||||||
|
if nodeAddress == "" {
|
||||||
|
// Default to localhost with the port (panel will match by port if address doesn't match exactly)
|
||||||
|
nodeAddress = fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get panel URL from saved config or environment variable
|
||||||
|
panelURL := savedConfig.PanelURL
|
||||||
|
if panelURL == "" {
|
||||||
|
panelURL = os.Getenv("PANEL_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeLogs.InitLogPusher(nodeAddress)
|
||||||
|
if panelURL != "" {
|
||||||
|
nodeLogs.SetPanelURL(panelURL)
|
||||||
|
}
|
||||||
|
// Connect log pusher to logger
|
||||||
|
logger.SetLogPusher(nodeLogs.PushLog)
|
||||||
|
|
||||||
xrayManager := xray.NewManager()
|
xrayManager := xray.NewManager()
|
||||||
server := api.NewServer(port, apiKey, xrayManager)
|
server := api.NewServer(port, apiKey, xrayManager)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
package xray
|
package xray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -469,3 +471,71 @@ func (m *Manager) GetStats(reset bool) (*NodeStats, error) {
|
||||||
OnlineClients: onlineList,
|
OnlineClients: onlineList,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogs returns XRAY access logs from the log file.
|
||||||
|
// Returns raw log lines as strings.
|
||||||
|
func (m *Manager) GetLogs(count int, filter string) ([]string, error) {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
|
||||||
|
if m.process == nil || !m.process.IsRunning() {
|
||||||
|
return nil, errors.New("XRAY is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access log path from current config
|
||||||
|
var pathToAccessLog string
|
||||||
|
if m.config != nil && len(m.config.LogConfig) > 0 {
|
||||||
|
var logConfig map[string]interface{}
|
||||||
|
if err := json.Unmarshal(m.config.LogConfig, &logConfig); err == nil {
|
||||||
|
if access, ok := logConfig["access"].(string); ok {
|
||||||
|
pathToAccessLog = access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to reading from file if not in config
|
||||||
|
if pathToAccessLog == "" {
|
||||||
|
var err error
|
||||||
|
pathToAccessLog, err = xray.GetAccessLogPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get access log path: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathToAccessLog == "none" || pathToAccessLog == "" {
|
||||||
|
return []string{}, nil // No logs configured
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(pathToAccessLog)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.Contains(line, "api -> api") {
|
||||||
|
continue // Skip empty lines and API calls
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter != "" && !strings.Contains(line, filter) {
|
||||||
|
continue // Apply filter if provided
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return last 'count' lines
|
||||||
|
if len(lines) > count {
|
||||||
|
lines = lines[len(lines)-count:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"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/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
|
|
@ -36,7 +41,12 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
|
|
||||||
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
||||||
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||||
// Main API group
|
// 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 := g.Group("/panel/api")
|
||||||
api.Use(a.checkAPIAuth)
|
api.Use(a.checkAPIAuth)
|
||||||
|
|
||||||
|
|
@ -56,3 +66,149 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
||||||
a.Tgbot.SendBackupToAdmins()
|
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"})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -37,6 +41,9 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/reload/:id", a.reloadNode)
|
g.POST("/reload/:id", a.reloadNode)
|
||||||
g.POST("/reloadAll", a.reloadAllNodes)
|
g.POST("/reloadAll", a.reloadAllNodes)
|
||||||
g.GET("/status/:id", a.getNodeStatus)
|
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.
|
// getNodes retrieves the list of all nodes.
|
||||||
|
|
@ -80,7 +87,7 @@ func (a *NodeController) getNode(c *gin.Context) {
|
||||||
jsonObj(c, node, nil)
|
jsonObj(c, node, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addNode creates a new node.
|
// addNode creates a new node and registers it with a generated API key.
|
||||||
func (a *NodeController) addNode(c *gin.Context) {
|
func (a *NodeController) addNode(c *gin.Context) {
|
||||||
node := &model.Node{}
|
node := &model.Node{}
|
||||||
err := c.ShouldBind(node)
|
err := c.ShouldBind(node)
|
||||||
|
|
@ -90,31 +97,42 @@ func (a *NodeController) addNode(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log received data for debugging
|
// Log received data for debugging
|
||||||
logger.Debugf("Adding node: name=%s, address=%s, apiKey=%s", node.Name, node.Address, node.ApiKey)
|
logger.Debugf("[Node: %s] Adding node: address=%s", node.Name, node.Address)
|
||||||
|
|
||||||
// Validate API key before saving
|
// Note: Connection check is done on frontend via /panel/node/check-connection endpoint
|
||||||
err = a.nodeService.ValidateApiKey(node)
|
// 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 {
|
if err != nil {
|
||||||
logger.Errorf("API key validation failed for node %s: %v", node.Address, err)
|
logger.Errorf("[Node: %s] Registration failed: %v", node.Name, err)
|
||||||
jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err)
|
jsonMsg(c, "Failed to register node: "+err.Error(), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the generated API key
|
||||||
|
node.ApiKey = apiKey
|
||||||
|
|
||||||
// Set default status
|
// Set default status
|
||||||
if node.Status == "" {
|
if node.Status == "" {
|
||||||
node.Status = "unknown"
|
node.Status = "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save node to database
|
||||||
err = a.nodeService.AddNode(node)
|
err = a.nodeService.AddNode(node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, "Failed to add node", err)
|
jsonMsg(c, "Failed to add node to database", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check health immediately
|
// Check health immediately
|
||||||
go a.nodeService.CheckNodeHealth(node)
|
go a.nodeService.CheckNodeHealth(node)
|
||||||
|
|
||||||
jsonMsgObj(c, "Node added successfully", node, nil)
|
// 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.
|
// updateNode updates an existing node.
|
||||||
|
|
@ -211,6 +229,9 @@ func (a *NodeController) updateNode(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast nodes update via WebSocket
|
||||||
|
a.broadcastNodesUpdate()
|
||||||
|
|
||||||
jsonMsgObj(c, "Node updated successfully", node, nil)
|
jsonMsgObj(c, "Node updated successfully", node, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +249,9 @@ func (a *NodeController) deleteNode(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast nodes update via WebSocket
|
||||||
|
a.broadcastNodesUpdate()
|
||||||
|
|
||||||
jsonMsg(c, "Node deleted successfully", nil)
|
jsonMsg(c, "Node deleted successfully", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,12 +275,20 @@ func (a *NodeController) checkNode(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast nodes update via WebSocket (to update status and response time)
|
||||||
|
a.broadcastNodesUpdate()
|
||||||
|
|
||||||
jsonMsgObj(c, "Node health check completed", node, nil)
|
jsonMsgObj(c, "Node health check completed", node, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAllNodes checks the health of all nodes.
|
// checkAllNodes checks the health of all nodes.
|
||||||
func (a *NodeController) checkAllNodes(c *gin.Context) {
|
func (a *NodeController) checkAllNodes(c *gin.Context) {
|
||||||
a.nodeService.CheckAllNodesHealth()
|
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)
|
jsonMsg(c, "Health check initiated for all nodes", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,3 +349,213 @@ func (a *NodeController) reloadAllNodes(c *gin.Context) {
|
||||||
|
|
||||||
jsonMsg(c, "All nodes reloaded successfully", nil)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,8 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
|
||||||
blackholes = []string{"blocked"}
|
blackholes = []string{"blocked"}
|
||||||
}
|
}
|
||||||
|
|
||||||
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
|
nodeId := c.PostForm("nodeId")
|
||||||
|
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes, nodeId)
|
||||||
jsonObj(c, logs, nil)
|
jsonObj(c, logs, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,15 @@
|
||||||
</a-icon>
|
</a-icon>
|
||||||
</template>
|
</template>
|
||||||
<a-form layout="inline">
|
<a-form layout="inline">
|
||||||
|
<a-form-item class="mr-05" v-if="multiNodeMode" label="Node:">
|
||||||
|
<a-select size="small" v-model="xraylogModal.nodeId" :style="{ width: '180px' }" @change="openXrayLogs()"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme" placeholder="Select Node">
|
||||||
|
<a-select-option value="">All Nodes</a-select-option>
|
||||||
|
<a-select-option v-for="node in xraylogModal.nodes" :key="node.id" :value="node.id.toString()">
|
||||||
|
[[ node.name || 'Node ' + node.id ]]
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item class="mr-05">
|
<a-form-item class="mr-05">
|
||||||
<a-input-group compact>
|
<a-input-group compact>
|
||||||
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
|
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
|
||||||
|
|
@ -834,10 +843,14 @@
|
||||||
visible: false,
|
visible: false,
|
||||||
logs: [],
|
logs: [],
|
||||||
rows: 20,
|
rows: 20,
|
||||||
|
filter: '',
|
||||||
showDirect: true,
|
showDirect: true,
|
||||||
showBlocked: true,
|
showBlocked: true,
|
||||||
showProxy: true,
|
showProxy: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
multiNodeMode: false,
|
||||||
|
nodes: [],
|
||||||
|
nodeId: '',
|
||||||
show(logs) {
|
show(logs) {
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
this.logs = logs;
|
this.logs = logs;
|
||||||
|
|
@ -944,11 +957,31 @@
|
||||||
const msg = await HttpUtil.post("/panel/setting/all");
|
const msg = await HttpUtil.post("/panel/setting/all");
|
||||||
if (msg && msg.success && msg.obj) {
|
if (msg && msg.success && msg.obj) {
|
||||||
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
|
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
|
||||||
|
xraylogModal.multiNodeMode = this.multiNodeMode;
|
||||||
|
// Load nodes if multi-node mode is enabled
|
||||||
|
if (this.multiNodeMode) {
|
||||||
|
await this.loadNodesForLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load multi-node mode:", e);
|
console.warn("Failed to load multi-node mode:", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loadNodesForLogs() {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get("/panel/node/list");
|
||||||
|
if (msg && msg.success && msg.obj) {
|
||||||
|
xraylogModal.nodes = msg.obj.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name || 'Node ' + node.id,
|
||||||
|
address: node.address || '',
|
||||||
|
status: node.status || 'unknown'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load nodes for logs:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
async getStatus() {
|
async getStatus() {
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||||
|
|
@ -1075,12 +1108,45 @@
|
||||||
logModal.loading = false;
|
logModal.loading = false;
|
||||||
},
|
},
|
||||||
async openXrayLogs() {
|
async openXrayLogs() {
|
||||||
xraylogModal.loading = true;
|
// Ensure multi-node mode is loaded and nodes are available
|
||||||
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
|
if (this.multiNodeMode && xraylogModal.nodes.length === 0) {
|
||||||
if (!msg.success) {
|
await this.loadNodesForLogs();
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
xraylogModal.loading = true;
|
||||||
|
const params = {
|
||||||
|
filter: xraylogModal.filter,
|
||||||
|
showDirect: xraylogModal.showDirect,
|
||||||
|
showBlocked: xraylogModal.showBlocked,
|
||||||
|
showProxy: xraylogModal.showProxy
|
||||||
|
};
|
||||||
|
|
||||||
|
// If multi-node mode and nodeId is selected, use node-specific endpoint
|
||||||
|
if (this.multiNodeMode && xraylogModal.nodeId) {
|
||||||
|
const msg = await HttpUtil.post('/panel/node/logs/' + xraylogModal.nodeId, {
|
||||||
|
count: xraylogModal.rows,
|
||||||
|
filter: xraylogModal.filter,
|
||||||
|
showDirect: xraylogModal.showDirect,
|
||||||
|
showBlocked: xraylogModal.showBlocked,
|
||||||
|
showProxy: xraylogModal.showProxy
|
||||||
|
});
|
||||||
|
if (!msg.success) {
|
||||||
|
xraylogModal.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xraylogModal.show(msg.obj);
|
||||||
|
} else {
|
||||||
|
// Use standard endpoint with optional nodeId
|
||||||
|
if (xraylogModal.nodeId) {
|
||||||
|
params.nodeId = xraylogModal.nodeId;
|
||||||
|
}
|
||||||
|
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, params);
|
||||||
|
if (!msg.success) {
|
||||||
|
xraylogModal.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xraylogModal.show(msg.obj);
|
||||||
}
|
}
|
||||||
xraylogModal.show(msg.obj);
|
|
||||||
await PromiseUtil.sleep(500);
|
await PromiseUtil.sleep(500);
|
||||||
xraylogModal.loading = false;
|
xraylogModal.loading = false;
|
||||||
},
|
},
|
||||||
|
|
@ -1165,6 +1231,13 @@
|
||||||
}, 2000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
'xraylogModal.visible'(newVal) {
|
||||||
|
if (newVal && this.multiNodeMode && xraylogModal.nodes.length === 0) {
|
||||||
|
this.loadNodesForLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
if (window.location.protocol !== "https:") {
|
if (window.location.protocol !== "https:") {
|
||||||
this.showAlert = true;
|
this.showAlert = true;
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,73 @@
|
||||||
{{define "modals/nodeModal"}}
|
{{define "modals/nodeModal"}}
|
||||||
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
|
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
|
||||||
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600">
|
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600"
|
||||||
<a-form layout="vertical">
|
:confirm-loading="nodeModal.registering" :ok-button-props="{ disabled: nodeModal.registering }">
|
||||||
<a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
|
<div v-if="!nodeModal.registering && !nodeModal.showProgress">
|
||||||
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
|
<a-form layout="vertical">
|
||||||
</a-form-item>
|
<a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
|
||||||
<a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
|
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
|
||||||
<a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
|
</a-form-item>
|
||||||
</a-form-item>
|
<a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
|
||||||
<a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
|
<a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
|
||||||
<a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
|
</a-form-item>
|
||||||
</a-form-item>
|
<a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
|
||||||
<a-form-item label='{{ i18n "pages.nodes.nodeApiKey" }}'>
|
<a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
|
||||||
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder='{{ i18n "pages.nodes.enterApiKey" }}'></a-input-password>
|
</a-form-item>
|
||||||
</a-form-item>
|
<!-- API key is now auto-generated during registration, no need for user input -->
|
||||||
</a-form>
|
</a-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress animation during registration -->
|
||||||
|
<div v-if="nodeModal.showProgress" style="padding: 20px 0; text-align: center;">
|
||||||
|
<a-steps :current="nodeModal.currentStep" direction="vertical" size="small">
|
||||||
|
<a-step title='{{ i18n "pages.nodes.connecting" }}' :status="nodeModal.steps.connecting">
|
||||||
|
<template slot="description">
|
||||||
|
<a-spin v-if="nodeModal.steps.connecting === 'process'" size="small" style="margin-right: 8px;"></a-spin>
|
||||||
|
<span v-if="nodeModal.steps.connecting === 'finish'">✓ {{ i18n "pages.nodes.connectionEstablished" }}</span>
|
||||||
|
<span v-if="nodeModal.steps.connecting === 'error'">✗ {{ i18n "pages.nodes.connectionError" }}</span>
|
||||||
|
</template>
|
||||||
|
</a-step>
|
||||||
|
<a-step title='{{ i18n "pages.nodes.generatingApiKey" }}' :status="nodeModal.steps.generating">
|
||||||
|
<template slot="description">
|
||||||
|
<a-spin v-if="nodeModal.steps.generating === 'process'" size="small" style="margin-right: 8px;"></a-spin>
|
||||||
|
<span v-if="nodeModal.steps.generating === 'finish'">✓ {{ i18n "pages.nodes.apiKeyGenerated" }}</span>
|
||||||
|
<span v-if="nodeModal.steps.generating === 'error'">✗ {{ i18n "pages.nodes.generationError" }}</span>
|
||||||
|
</template>
|
||||||
|
</a-step>
|
||||||
|
<a-step title='{{ i18n "pages.nodes.registeringNode" }}' :status="nodeModal.steps.registering">
|
||||||
|
<template slot="description">
|
||||||
|
<a-spin v-if="nodeModal.steps.registering === 'process'" size="small" style="margin-right: 8px;"></a-spin>
|
||||||
|
<span v-if="nodeModal.steps.registering === 'finish'">✓ {{ i18n "pages.nodes.nodeRegistered" }}</span>
|
||||||
|
<span v-if="nodeModal.steps.registering === 'error'">✗ {{ i18n "pages.nodes.registrationError" }}</span>
|
||||||
|
</template>
|
||||||
|
</a-step>
|
||||||
|
<a-step title='{{ i18n "pages.nodes.done" }}' :status="nodeModal.steps.completed">
|
||||||
|
<template slot="description">
|
||||||
|
<span v-if="nodeModal.steps.completed === 'finish'" style="color: #52c41a; font-weight: bold;">✓ {{ i18n "pages.nodes.nodeAddedSuccessfully" }}</span>
|
||||||
|
</template>
|
||||||
|
</a-step>
|
||||||
|
</a-steps>
|
||||||
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
<script>
|
<script>
|
||||||
const nodeModal = window.nodeModal = {
|
const nodeModal = window.nodeModal = {
|
||||||
visible: false,
|
visible: false,
|
||||||
title: '',
|
title: '',
|
||||||
okText: 'OK',
|
okText: 'OK',
|
||||||
|
registering: false,
|
||||||
|
showProgress: false,
|
||||||
|
currentStep: 0,
|
||||||
|
steps: {
|
||||||
|
connecting: 'wait',
|
||||||
|
generating: 'wait',
|
||||||
|
registering: 'wait',
|
||||||
|
completed: 'wait'
|
||||||
|
},
|
||||||
formData: {
|
formData: {
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
port: 8080,
|
port: 8080
|
||||||
apiKey: ''
|
// apiKey is now auto-generated during registration
|
||||||
},
|
},
|
||||||
ok() {
|
ok() {
|
||||||
// Валидация полей - используем nodeModal напрямую для правильного контекста
|
// Валидация полей - используем nodeModal напрямую для правильного контекста
|
||||||
|
|
@ -47,14 +89,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nodeModal.formData.apiKey || !nodeModal.formData.apiKey.trim()) {
|
// API key is now auto-generated during registration, no validation needed
|
||||||
if (typeof app !== 'undefined' && app.$message) {
|
|
||||||
app.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
|
|
||||||
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
|
|
||||||
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если все поля заполнены, формируем полный адрес с портом
|
// Если все поля заполнены, формируем полный адрес с портом
|
||||||
const dataToSend = { ...nodeModal.formData };
|
const dataToSend = { ...nodeModal.formData };
|
||||||
|
|
@ -80,19 +115,51 @@
|
||||||
delete dataToSend.port;
|
delete dataToSend.port;
|
||||||
dataToSend.address = fullAddress;
|
dataToSend.address = fullAddress;
|
||||||
|
|
||||||
// Вызываем confirm с объединенным адресом
|
// Если это режим редактирования, просто вызываем confirm
|
||||||
|
if (nodeModal.isEdit) {
|
||||||
|
if (nodeModal.confirm) {
|
||||||
|
nodeModal.confirm(dataToSend);
|
||||||
|
}
|
||||||
|
nodeModal.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для добавления новой ноды показываем прогресс регистрации
|
||||||
|
nodeModal.registering = true;
|
||||||
|
nodeModal.showProgress = true;
|
||||||
|
nodeModal.currentStep = 0;
|
||||||
|
|
||||||
|
// Сброс всех шагов
|
||||||
|
nodeModal.steps = {
|
||||||
|
connecting: 'wait',
|
||||||
|
generating: 'wait',
|
||||||
|
registering: 'wait',
|
||||||
|
completed: 'wait'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вызываем confirm с объединенным адресом (это запустит регистрацию)
|
||||||
if (nodeModal.confirm) {
|
if (nodeModal.confirm) {
|
||||||
nodeModal.confirm(dataToSend);
|
nodeModal.confirm(dataToSend);
|
||||||
}
|
}
|
||||||
nodeModal.visible = false;
|
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
|
this.resetProgress();
|
||||||
},
|
},
|
||||||
show({ title = '', okText = 'OK', node = null, confirm = (data) => { }, isEdit = false }) {
|
show({ title = '', okText = 'OK', node = null, confirm = (data) => { }, isEdit = false }) {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.okText = okText;
|
this.okText = okText;
|
||||||
this.confirm = confirm;
|
this.confirm = confirm;
|
||||||
|
this.isEdit = isEdit;
|
||||||
|
this.registering = false;
|
||||||
|
this.showProgress = false;
|
||||||
|
this.currentStep = 0;
|
||||||
|
this.steps = {
|
||||||
|
connecting: 'wait',
|
||||||
|
generating: 'wait',
|
||||||
|
registering: 'wait',
|
||||||
|
completed: 'wait'
|
||||||
|
};
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
// Извлекаем адрес и порт из полного URL
|
// Извлекаем адрес и порт из полного URL
|
||||||
|
|
@ -119,15 +186,15 @@
|
||||||
this.formData = {
|
this.formData = {
|
||||||
name: node.name || '',
|
name: node.name || '',
|
||||||
address: address,
|
address: address,
|
||||||
port: port,
|
port: port
|
||||||
apiKey: node.apiKey || ''
|
// apiKey is not shown in edit mode (it's managed by the system)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.formData = {
|
this.formData = {
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
port: 8080,
|
port: 8080
|
||||||
apiKey: ''
|
// apiKey is auto-generated during registration
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,6 +202,18 @@
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
|
this.resetProgress();
|
||||||
|
},
|
||||||
|
resetProgress() {
|
||||||
|
this.registering = false;
|
||||||
|
this.showProgress = false;
|
||||||
|
this.currentStep = 0;
|
||||||
|
this.steps = {
|
||||||
|
connecting: 'wait',
|
||||||
|
generating: 'wait',
|
||||||
|
registering: 'wait',
|
||||||
|
completed: 'wait'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@
|
||||||
[[ node.status || 'unknown' ]]
|
[[ node.status || 'unknown' ]]
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template slot="responseTime" slot-scope="text, node">
|
||||||
|
<span v-if="node.responseTime && node.responseTime > 0" :style="{
|
||||||
|
color: node.responseTime < 100 ? '#52c41a' : node.responseTime < 300 ? '#faad14' : '#ff4d4f',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}">
|
||||||
|
[[ node.responseTime ]] ms
|
||||||
|
</span>
|
||||||
|
<span v-else style="color: #999;">-</span>
|
||||||
|
</template>
|
||||||
<template slot="inbounds" slot-scope="text, node">
|
<template slot="inbounds" slot-scope="text, node">
|
||||||
<template v-if="node.inbounds && node.inbounds.length > 0">
|
<template v-if="node.inbounds && node.inbounds.length > 0">
|
||||||
<a-tag v-for="(inbound, index) in node.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
|
<a-tag v-for="(inbound, index) in node.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
|
||||||
|
|
@ -135,6 +144,11 @@
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 80,
|
width: 80,
|
||||||
scopedSlots: { customRender: 'status' },
|
scopedSlots: { customRender: 'status' },
|
||||||
|
}, {
|
||||||
|
title: '{{ i18n "pages.nodes.responseTime" }}',
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
scopedSlots: { customRender: 'responseTime' },
|
||||||
}, {
|
}, {
|
||||||
title: '{{ i18n "pages.nodes.assignedInbounds" }}',
|
title: '{{ i18n "pages.nodes.assignedInbounds" }}',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -182,6 +196,7 @@
|
||||||
reloadingAll: false,
|
reloadingAll: false,
|
||||||
editingNodeId: null,
|
editingNodeId: null,
|
||||||
editingNodeName: '',
|
editingNodeName: '',
|
||||||
|
pollInterval: null,
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadNodes() {
|
async loadNodes() {
|
||||||
|
|
@ -194,6 +209,7 @@
|
||||||
name: node.name || '',
|
name: node.name || '',
|
||||||
address: node.address || '',
|
address: node.address || '',
|
||||||
status: node.status || 'unknown',
|
status: node.status || 'unknown',
|
||||||
|
responseTime: node.responseTime || 0,
|
||||||
inbounds: node.inbounds || []
|
inbounds: node.inbounds || []
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -427,26 +443,169 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async submitNode(nodeData, isEdit, nodeId = null) {
|
async submitNode(nodeData, isEdit, nodeId = null) {
|
||||||
|
// Для редактирования используем обычный процесс
|
||||||
|
if (isEdit) {
|
||||||
|
try {
|
||||||
|
const url = `/panel/node/update/${nodeId}`;
|
||||||
|
const msg = await HttpUtil.post(url, nodeData);
|
||||||
|
if (msg && msg.success) {
|
||||||
|
app.$message.success('{{ i18n "pages.nodes.updateSuccess" }}');
|
||||||
|
await this.loadNodes();
|
||||||
|
if (window.nodeModal) {
|
||||||
|
window.nodeModal.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.updateError" }}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update node:', e);
|
||||||
|
app.$message.error('{{ i18n "pages.nodes.updateError" }}');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для добавления новой ноды показываем прогресс регистрации
|
||||||
|
const modal = window.nodeModal;
|
||||||
|
if (!modal) {
|
||||||
|
app.$message.error('Modal not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = isEdit ? `/panel/node/update/${nodeId}` : '/panel/node/add';
|
// Шаг 1: Устанавливаю соединение
|
||||||
|
modal.currentStep = 0;
|
||||||
|
modal.steps.connecting = 'process';
|
||||||
|
|
||||||
|
// Проверяем доступность ноды через панель (избегаем CORS)
|
||||||
|
try {
|
||||||
|
const checkMsg = await HttpUtil.post('/panel/node/check-connection', {
|
||||||
|
address: nodeData.address
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkMsg || !checkMsg.success) {
|
||||||
|
modal.steps.connecting = 'error';
|
||||||
|
app.$message.error(checkMsg?.msg || 'Нода недоступна. Проверьте адрес и порт.');
|
||||||
|
modal.registering = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
modal.steps.connecting = 'error';
|
||||||
|
app.$message.error('Нода недоступна. Проверьте адрес и порт.');
|
||||||
|
modal.registering = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.steps.connecting = 'finish';
|
||||||
|
modal.currentStep = 1;
|
||||||
|
|
||||||
|
// Небольшая задержка для визуального эффекта
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Шаг 2: Генерирую API ключ
|
||||||
|
modal.steps.generating = 'process';
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500)); // Имитация генерации
|
||||||
|
modal.steps.generating = 'finish';
|
||||||
|
modal.currentStep = 2;
|
||||||
|
|
||||||
|
// Небольшая задержка для визуального эффекта
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Шаг 3: Регистрирую ноду
|
||||||
|
modal.steps.registering = 'process';
|
||||||
|
const url = '/panel/node/add';
|
||||||
const msg = await HttpUtil.post(url, nodeData);
|
const msg = await HttpUtil.post(url, nodeData);
|
||||||
|
|
||||||
if (msg && msg.success) {
|
if (msg && msg.success) {
|
||||||
app.$message.success(isEdit ? '{{ i18n "pages.nodes.updateSuccess" }}' : '{{ i18n "pages.nodes.addSuccess" }}');
|
modal.steps.registering = 'finish';
|
||||||
|
modal.currentStep = 3;
|
||||||
|
|
||||||
|
// Небольшая задержка для визуального эффекта
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Шаг 4: Готово
|
||||||
|
modal.steps.completed = 'finish';
|
||||||
|
|
||||||
|
// Задержка перед закрытием модалки
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
|
||||||
await this.loadNodes();
|
await this.loadNodes();
|
||||||
if (window.nodeModal) {
|
if (window.nodeModal) {
|
||||||
window.nodeModal.close();
|
window.nodeModal.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app.$message.error((msg && msg.msg) || (isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}'));
|
modal.steps.registering = 'error';
|
||||||
|
app.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.addError" }}');
|
||||||
|
modal.registering = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to ${isEdit ? 'update' : 'add'} node:`, e);
|
console.error('Failed to add node:', e);
|
||||||
app.$message.error(isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}');
|
// Определяем на каком шаге произошла ошибка
|
||||||
|
if (modal.steps.connecting === 'process') {
|
||||||
|
modal.steps.connecting = 'error';
|
||||||
|
} else if (modal.steps.generating === 'process') {
|
||||||
|
modal.steps.generating = 'error';
|
||||||
|
} else if (modal.steps.registering === 'process') {
|
||||||
|
modal.steps.registering = 'error';
|
||||||
|
}
|
||||||
|
app.$message.error('{{ i18n "pages.nodes.addError" }}');
|
||||||
|
modal.registering = false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
startPolling() {
|
||||||
|
// Poll every 5 seconds as fallback
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
}
|
||||||
|
this.pollInterval = setInterval(() => {
|
||||||
|
this.loadNodes();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
// Clean up polling interval
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.loadNodes();
|
await this.loadNodes();
|
||||||
|
|
||||||
|
// Setup WebSocket for real-time updates
|
||||||
|
if (window.wsClient) {
|
||||||
|
window.wsClient.connect();
|
||||||
|
|
||||||
|
// Listen for nodes updates
|
||||||
|
window.wsClient.on('nodes', (payload) => {
|
||||||
|
if (payload && Array.isArray(payload)) {
|
||||||
|
this.nodes = payload.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name || '',
|
||||||
|
address: node.address || '',
|
||||||
|
status: node.status || 'unknown',
|
||||||
|
responseTime: node.responseTime || 0,
|
||||||
|
inbounds: node.inbounds || []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to polling if WebSocket fails
|
||||||
|
window.wsClient.on('error', () => {
|
||||||
|
console.warn('WebSocket connection failed, falling back to polling');
|
||||||
|
this.startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.wsClient.on('disconnected', () => {
|
||||||
|
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
|
||||||
|
console.warn('WebSocket reconnection failed, falling back to polling');
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to polling if WebSocket is not available
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@
|
||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckNodeHealthJob periodically checks the health of all nodes in multi-node mode.
|
// CheckNodeHealthJob periodically checks the health of all nodes in multi-node mode.
|
||||||
|
|
@ -38,14 +42,48 @@ func (j *CheckNodeHealthJob) Run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Checking health of %d nodes", len(nodes))
|
logger.Debugf("Checking health of %d nodes", len(nodes))
|
||||||
|
|
||||||
|
// Use a wait group to wait for all health checks to complete
|
||||||
|
var wg sync.WaitGroup
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
n := node // Capture loop variable
|
n := node // Capture loop variable
|
||||||
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
if err := j.nodeService.CheckNodeHealth(n); err != nil {
|
if err := j.nodeService.CheckNodeHealth(n); err != nil {
|
||||||
logger.Debugf("Node %s (%s) health check failed: %v", n.Name, n.Address, err)
|
logger.Debugf("[Node: %s] Health check failed: %v", n.Name, err)
|
||||||
} else {
|
} else {
|
||||||
logger.Debugf("Node %s (%s) is %s", n.Name, n.Address, n.Status)
|
logger.Debugf("[Node: %s] Status: %s, ResponseTime: %d ms", n.Name, n.Status, n.ResponseTime)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for all checks to complete, then broadcast update
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
// Get updated nodes with response times
|
||||||
|
updatedNodes, err := j.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(updatedNodes))
|
||||||
|
for _, node := range updatedNodes {
|
||||||
|
inbounds, _ := j.nodeService.GetInboundsForNode(node.Id)
|
||||||
|
result = append(result, NodeWithInbounds{
|
||||||
|
Node: node,
|
||||||
|
Inbounds: inbounds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast via WebSocket
|
||||||
|
websocket.BroadcastNodes(result)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
283
web/job/collect_node_logs_job.go
Normal file
283
web/job/collect_node_logs_job.go
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
// Package job provides scheduled background jobs for the 3x-ui panel.
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logEntry represents a log entry with timestamp for sorting
|
||||||
|
type logEntry struct {
|
||||||
|
timestamp time.Time
|
||||||
|
nodeName string
|
||||||
|
level string
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectNodeLogsJob periodically collects XRAY logs from nodes and adds them to the panel log buffer.
|
||||||
|
type CollectNodeLogsJob struct {
|
||||||
|
nodeService service.NodeService
|
||||||
|
// Track last collected log hash for each node and log type to avoid duplicates
|
||||||
|
lastLogHashes map[string]string // key: "nodeId:logType" (e.g., "1:service", "1:access")
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCollectNodeLogsJob creates a new job for collecting node logs.
|
||||||
|
func NewCollectNodeLogsJob() *CollectNodeLogsJob {
|
||||||
|
return &CollectNodeLogsJob{
|
||||||
|
nodeService: service.NodeService{},
|
||||||
|
lastLogHashes: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLogLine parses a log line from node and extracts timestamp, level, and message.
|
||||||
|
// Format: "timestamp level - message" or "timestamp level - message" for access logs.
|
||||||
|
// Returns timestamp, level, message, and success flag.
|
||||||
|
func (j *CollectNodeLogsJob) parseLogLine(logLine string) (time.Time, string, string, bool) {
|
||||||
|
// Try to parse format: "2006/01/02 15:04:05 level - message" or "2006/01/02 15:04:05.999999 level - message"
|
||||||
|
if idx := strings.Index(logLine, " - "); idx != -1 {
|
||||||
|
parts := strings.SplitN(logLine, " - ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// parts[0] = "timestamp level", parts[1] = "message"
|
||||||
|
levelPart := strings.TrimSpace(parts[0])
|
||||||
|
levelFields := strings.Fields(levelPart)
|
||||||
|
|
||||||
|
if len(levelFields) >= 3 {
|
||||||
|
// Format: "2006/01/02 15:04:05 level" or "2006/01/02 15:04:05.999999 level"
|
||||||
|
timestampStr := levelFields[0] + " " + levelFields[1]
|
||||||
|
level := strings.ToUpper(levelFields[2])
|
||||||
|
message := parts[1]
|
||||||
|
|
||||||
|
// Try parsing with microseconds first
|
||||||
|
timestamp, err := time.ParseInLocation("2006/01/02 15:04:05.999999", timestampStr, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to format without microseconds
|
||||||
|
timestamp, err = time.ParseInLocation("2006/01/02 15:04:05", timestampStr, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
return timestamp, level, message, true
|
||||||
|
}
|
||||||
|
} else if len(levelFields) >= 2 {
|
||||||
|
// Try to parse as "timestamp level" where timestamp might be in different format
|
||||||
|
level := strings.ToUpper(levelFields[len(levelFields)-1])
|
||||||
|
message := parts[1]
|
||||||
|
// Try to extract timestamp from first fields
|
||||||
|
if len(levelFields) >= 2 {
|
||||||
|
timestampStr := strings.Join(levelFields[:len(levelFields)-1], " ")
|
||||||
|
timestamp, err := time.ParseInLocation("2006/01/02 15:04:05.999999", timestampStr, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
timestamp, err = time.ParseInLocation("2006/01/02 15:04:05", timestampStr, time.Local)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return timestamp, level, message, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If timestamp parsing fails, use current time
|
||||||
|
return time.Now(), level, message, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If parsing fails, return current time and treat as INFO level
|
||||||
|
return time.Now(), "INFO", logLine, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// processLogs processes logs from a node and returns new log entries with timestamps.
|
||||||
|
// logType can be "service" or "access" to track them separately.
|
||||||
|
func (j *CollectNodeLogsJob) processLogs(node *model.Node, rawLogs []string, logType string) []logEntry {
|
||||||
|
if len(rawLogs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last collected log hash for this node and log type
|
||||||
|
hashKey := fmt.Sprintf("%d:%s", node.Id, logType)
|
||||||
|
j.mu.RLock()
|
||||||
|
lastHash := j.lastLogHashes[hashKey]
|
||||||
|
j.mu.RUnlock()
|
||||||
|
|
||||||
|
// Process logs from newest to oldest to find where we left off
|
||||||
|
var newLogLines []string
|
||||||
|
var mostRecentHash string
|
||||||
|
foundLastHash := lastHash == "" // If no last hash, all logs are new
|
||||||
|
|
||||||
|
// Iterate from newest (end) to oldest (start)
|
||||||
|
for i := len(rawLogs) - 1; i >= 0; i-- {
|
||||||
|
logLine := rawLogs[i]
|
||||||
|
if logLine == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip API calls for access logs
|
||||||
|
if logType == "access" && strings.Contains(logLine, "api -> api") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hash for this log line
|
||||||
|
logHash := fmt.Sprintf("%x", md5.Sum([]byte(logLine)))
|
||||||
|
|
||||||
|
// Store the most recent hash (first valid log we encounter)
|
||||||
|
if mostRecentHash == "" {
|
||||||
|
mostRecentHash = logHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't found the last collected log yet, check if this is it
|
||||||
|
if !foundLastHash {
|
||||||
|
if logHash == lastHash {
|
||||||
|
foundLastHash = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a new log (after the last collected one)
|
||||||
|
newLogLines = append(newLogLines, logLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't find the last hash, all logs in this batch are new
|
||||||
|
if !foundLastHash {
|
||||||
|
// Add all valid logs as new
|
||||||
|
for i := len(rawLogs) - 1; i >= 0; i-- {
|
||||||
|
logLine := rawLogs[i]
|
||||||
|
if logLine != "" {
|
||||||
|
if logType == "access" && strings.Contains(logLine, "api -> api") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newLogLines = append(newLogLines, logLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse logs and create entries with timestamps
|
||||||
|
var entries []logEntry
|
||||||
|
for _, logLine := range newLogLines {
|
||||||
|
timestamp, level, message, _ := j.parseLogLine(logLine)
|
||||||
|
entries = append(entries, logEntry{
|
||||||
|
timestamp: timestamp,
|
||||||
|
nodeName: node.Name,
|
||||||
|
level: level,
|
||||||
|
message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last hash to the most recent log we processed (newest log from batch)
|
||||||
|
if mostRecentHash != "" {
|
||||||
|
j.mu.Lock()
|
||||||
|
j.lastLogHashes[hashKey] = mostRecentHash
|
||||||
|
j.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the job to collect logs from all nodes and add them to the panel log buffer.
|
||||||
|
func (j *CollectNodeLogsJob) Run() {
|
||||||
|
// Check if multi-node mode is enabled
|
||||||
|
settingService := service.SettingService{}
|
||||||
|
multiMode, err := settingService.GetMultiNodeMode()
|
||||||
|
if err != nil || !multiMode {
|
||||||
|
return // Skip if multi-node mode is not enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := j.nodeService.GetAllNodes()
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Failed to get nodes for log collection: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return // No nodes to collect logs from
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all logs from all nodes first, then sort and add to buffer
|
||||||
|
var allEntries []logEntry
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var entriesMu sync.Mutex
|
||||||
|
|
||||||
|
// Collect logs from each node concurrently
|
||||||
|
// Only collect from nodes that have assigned inbounds (active nodes)
|
||||||
|
for _, node := range nodes {
|
||||||
|
n := node // Capture loop variable
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Check if node has assigned inbounds (only collect from active nodes)
|
||||||
|
inbounds, err := j.nodeService.GetInboundsForNode(n.Id)
|
||||||
|
if err != nil || len(inbounds) == 0 {
|
||||||
|
return // Skip nodes without assigned inbounds
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeEntries []logEntry
|
||||||
|
|
||||||
|
// Collect service logs (node service and XRAY core logs)
|
||||||
|
serviceLogs, err := j.nodeService.GetNodeServiceLogs(n, 100, "debug")
|
||||||
|
if err != nil {
|
||||||
|
// Don't log errors for offline nodes
|
||||||
|
if !strings.Contains(err.Error(), "status code") {
|
||||||
|
logger.Debugf("[Node: %s] Failed to collect service logs: %v", n.Name, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Process service logs
|
||||||
|
nodeEntries = append(nodeEntries, j.processLogs(n, serviceLogs, "service")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get XRAY access logs (traffic logs)
|
||||||
|
rawLogs, err := j.nodeService.GetNodeLogs(n, 100, "")
|
||||||
|
if err != nil {
|
||||||
|
// Don't log errors for offline nodes or nodes without logs configured
|
||||||
|
if !strings.Contains(err.Error(), "XRAY is not running") &&
|
||||||
|
!strings.Contains(err.Error(), "status code") &&
|
||||||
|
!strings.Contains(err.Error(), "access log path") {
|
||||||
|
logger.Debugf("[Node: %s] Failed to collect access logs: %v", n.Name, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Process access logs
|
||||||
|
nodeEntries = append(nodeEntries, j.processLogs(n, rawLogs, "access")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add node entries to global list
|
||||||
|
if len(nodeEntries) > 0 {
|
||||||
|
entriesMu.Lock()
|
||||||
|
allEntries = append(allEntries, nodeEntries...)
|
||||||
|
entriesMu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all goroutines to finish
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Sort all entries by timestamp (oldest first)
|
||||||
|
sort.Slice(allEntries, func(i, j int) bool {
|
||||||
|
return allEntries[i].timestamp.Before(allEntries[j].timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add sorted logs to panel buffer
|
||||||
|
for _, entry := range allEntries {
|
||||||
|
formattedMessage := fmt.Sprintf("[Node: %s] %s", entry.nodeName, entry.message)
|
||||||
|
switch entry.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allEntries) > 0 {
|
||||||
|
logger.Debugf("Collected and sorted %d new log entries from %d nodes", len(allEntries), len(nodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -47,6 +48,71 @@ func (s *NodeService) AddNode(node *model.Node) error {
|
||||||
return db.Create(node).Error
|
return db.Create(node).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterNode registers a node by sending it an API key generated by the panel.
|
||||||
|
// This method generates a unique API key, sends it to the node, and returns the key.
|
||||||
|
func (s *NodeService) RegisterNode(node *model.Node) (string, error) {
|
||||||
|
// Generate a unique API key (32 characters, alphanumeric)
|
||||||
|
apiKey := random.Seq(32)
|
||||||
|
|
||||||
|
// Determine panel URL to send to node
|
||||||
|
settingService := SettingService{}
|
||||||
|
protocol := "http"
|
||||||
|
if certFile, _ := settingService.GetCertFile(); certFile != "" {
|
||||||
|
protocol = "https"
|
||||||
|
}
|
||||||
|
listenIP, _ := settingService.GetListen()
|
||||||
|
listenPort, _ := settingService.GetPort()
|
||||||
|
basePath, _ := settingService.GetBasePath()
|
||||||
|
|
||||||
|
panelURL := fmt.Sprintf("%s://%s:%d%s", protocol, listenIP, listenPort, basePath)
|
||||||
|
|
||||||
|
// Prepare registration request
|
||||||
|
registerData := map[string]interface{}{
|
||||||
|
"apiKey": apiKey,
|
||||||
|
"panelUrl": panelURL,
|
||||||
|
"nodeAddress": node.Address,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send registration request to node
|
||||||
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerURL := fmt.Sprintf("%s/api/v1/register", node.Address)
|
||||||
|
jsonData, err := json.Marshal(registerData)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal registration data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", registerURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create registration request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to register node: %w (check if node is accessible at %s)", err, node.Address)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("node registration failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response to verify registration
|
||||||
|
var registerResp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse registration response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("[Node: %s] Successfully registered node with API key", node.Name)
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateNode updates an existing node.
|
// UpdateNode updates an existing node.
|
||||||
// Only updates fields that are provided (non-empty for strings, non-zero for integers).
|
// Only updates fields that are provided (non-empty for strings, non-zero for integers).
|
||||||
func (s *NodeService) UpdateNode(node *model.Node) error {
|
func (s *NodeService) UpdateNode(node *model.Node) error {
|
||||||
|
|
@ -83,11 +149,18 @@ func (s *NodeService) UpdateNode(node *model.Node) error {
|
||||||
}
|
}
|
||||||
updates["insecure_tls"] = node.InsecureTLS
|
updates["insecure_tls"] = node.InsecureTLS
|
||||||
|
|
||||||
// Update status and last_check if provided (these are usually set by health checks, not user edits)
|
// Update status, response_time, and last_check if provided (these are usually set by health checks, not user edits)
|
||||||
if node.Status != "" && node.Status != existingNode.Status {
|
if node.Status != "" && node.Status != existingNode.Status {
|
||||||
updates["status"] = node.Status
|
updates["status"] = node.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if node.ResponseTime > 0 && node.ResponseTime != existingNode.ResponseTime {
|
||||||
|
updates["response_time"] = node.ResponseTime
|
||||||
|
} else if node.ResponseTime == 0 && existingNode.ResponseTime > 0 {
|
||||||
|
// Allow resetting to 0 (e.g., on error)
|
||||||
|
updates["response_time"] = 0
|
||||||
|
}
|
||||||
|
|
||||||
if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck {
|
if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck {
|
||||||
updates["last_check"] = node.LastCheck
|
updates["last_check"] = node.LastCheck
|
||||||
}
|
}
|
||||||
|
|
@ -116,18 +189,27 @@ func (s *NodeService) DeleteNode(id int) error {
|
||||||
return db.Delete(&model.Node{}, id).Error
|
return db.Delete(&model.Node{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckNodeHealth checks if a node is online and updates its status.
|
// CheckNodeHealth checks if a node is online and updates its status and response time.
|
||||||
func (s *NodeService) CheckNodeHealth(node *model.Node) error {
|
func (s *NodeService) CheckNodeHealth(node *model.Node) error {
|
||||||
status, err := s.CheckNodeStatus(node)
|
status, responseTime, err := s.CheckNodeStatus(node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
node.Status = "error"
|
node.Status = "error"
|
||||||
|
node.ResponseTime = 0 // Set to 0 on error
|
||||||
node.LastCheck = time.Now().Unix()
|
node.LastCheck = time.Now().Unix()
|
||||||
s.UpdateNode(node)
|
if updateErr := s.UpdateNode(node); updateErr != nil {
|
||||||
|
logger.Errorf("[Node: %s] Failed to update node status: %v", node.Name, updateErr)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
node.Status = status
|
node.Status = status
|
||||||
|
node.ResponseTime = responseTime
|
||||||
node.LastCheck = time.Now().Unix()
|
node.LastCheck = time.Now().Unix()
|
||||||
return s.UpdateNode(node)
|
logger.Debugf("[Node: %s] Health check: status=%s, responseTime=%d ms", node.Name, status, responseTime)
|
||||||
|
if updateErr := s.UpdateNode(node); updateErr != nil {
|
||||||
|
logger.Errorf("[Node: %s] Failed to update node with response time: %v", node.Name, updateErr)
|
||||||
|
return updateErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createHTTPClient creates an HTTP client configured for the node's TLS settings.
|
// createHTTPClient creates an HTTP client configured for the node's TLS settings.
|
||||||
|
|
@ -172,24 +254,30 @@ func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration)
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckNodeStatus performs a health check on a given node.
|
// CheckNodeStatus performs a health check on a given node and measures response time.
|
||||||
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
|
// Returns status string and response time in milliseconds.
|
||||||
|
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, int64, error) {
|
||||||
client, err := s.createHTTPClient(node, 5*time.Second)
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "error", err
|
return "error", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/health", node.Address)
|
url := fmt.Sprintf("%s/health", node.Address)
|
||||||
|
|
||||||
|
// Measure response time
|
||||||
|
startTime := time.Now()
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
|
responseTime := time.Since(startTime).Milliseconds()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "offline", err
|
return "offline", 0, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
return "online", nil
|
return "online", responseTime, nil
|
||||||
}
|
}
|
||||||
return "error", fmt.Errorf("node returned status code %d", resp.StatusCode)
|
return "error", 0, fmt.Errorf("node returned status code %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAllNodesHealth checks health of all nodes.
|
// CheckAllNodesHealth checks health of all nodes.
|
||||||
|
|
@ -383,10 +471,10 @@ func (s *NodeService) CollectNodeStats() error {
|
||||||
strings.Contains(errMsg, "status code 404") ||
|
strings.Contains(errMsg, "status code 404") ||
|
||||||
strings.Contains(errMsg, "status code 500") {
|
strings.Contains(errMsg, "status code 500") {
|
||||||
// These are expected errors, log as debug only
|
// These are expected errors, log as debug only
|
||||||
logger.Debugf("Skipping stats collection from node %s (ID: %d): %v", result.node.Name, result.node.Id, result.err)
|
logger.Debugf("[Node: %s] Skipping stats collection: %v", result.node.Name, result.err)
|
||||||
} else {
|
} else {
|
||||||
// Unexpected errors should be logged as warning
|
// Unexpected errors should be logged as warning
|
||||||
logger.Warningf("Failed to get stats from node %s (ID: %d): %v", result.node.Name, result.node.Id, result.err)
|
logger.Warningf("[Node: %s] Failed to get stats: %v", result.node.Name, result.err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -481,8 +569,24 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err
|
||||||
return fmt.Errorf("failed to create HTTP client: %w", err)
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get panel URL to send to node
|
||||||
|
panelURL := s.getPanelURL()
|
||||||
|
|
||||||
|
// Prepare request body with config and panel URL
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"config": json.RawMessage(xrayConfig),
|
||||||
|
}
|
||||||
|
if panelURL != "" {
|
||||||
|
requestBody["panelUrl"] = panelURL
|
||||||
|
}
|
||||||
|
|
||||||
|
requestJSON, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
|
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(xrayConfig))
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -504,6 +608,54 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPanelURL constructs the panel URL from settings.
|
||||||
|
func (s *NodeService) getPanelURL() string {
|
||||||
|
settingService := SettingService{}
|
||||||
|
|
||||||
|
// Get panel settings
|
||||||
|
webListen, _ := settingService.GetListen()
|
||||||
|
webPort, _ := settingService.GetPort()
|
||||||
|
webDomain, _ := settingService.GetWebDomain()
|
||||||
|
webCertFile, _ := settingService.GetCertFile()
|
||||||
|
webKeyFile, _ := settingService.GetKeyFile()
|
||||||
|
webBasePath, _ := settingService.GetBasePath()
|
||||||
|
|
||||||
|
// Determine protocol
|
||||||
|
protocol := "http"
|
||||||
|
if webCertFile != "" || webKeyFile != "" {
|
||||||
|
protocol = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine host
|
||||||
|
host := webDomain
|
||||||
|
if host == "" {
|
||||||
|
host = webListen
|
||||||
|
if host == "" {
|
||||||
|
// If no listen IP specified, use localhost (node should be able to reach panel)
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct URL
|
||||||
|
url := fmt.Sprintf("%s://%s", protocol, host)
|
||||||
|
if webPort > 0 && webPort != 80 && webPort != 443 {
|
||||||
|
url += fmt.Sprintf(":%d", webPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base path (remove trailing slash if present, we'll add it in node)
|
||||||
|
basePath := webBasePath
|
||||||
|
if basePath != "" && basePath != "/" {
|
||||||
|
if !strings.HasSuffix(basePath, "/") {
|
||||||
|
basePath += "/"
|
||||||
|
}
|
||||||
|
url += basePath
|
||||||
|
} else {
|
||||||
|
url += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
// ReloadNode reloads XRAY on a specific node.
|
// ReloadNode reloads XRAY on a specific node.
|
||||||
func (s *NodeService) ReloadNode(node *model.Node) error {
|
func (s *NodeService) ReloadNode(node *model.Node) error {
|
||||||
client, err := s.createHTTPClient(node, 30*time.Second)
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
||||||
|
|
@ -608,7 +760,7 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
||||||
healthURL := fmt.Sprintf("%s/health", node.Address)
|
healthURL := fmt.Sprintf("%s/health", node.Address)
|
||||||
healthResp, err := client.Get(healthURL)
|
healthResp, err := client.Get(healthURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to connect to node %s at %s: %v", node.Address, healthURL, err)
|
logger.Errorf("[Node: %s] Failed to connect at %s: %v", node.Name, healthURL, err)
|
||||||
return fmt.Errorf("failed to connect to node: %v", err)
|
return fmt.Errorf("failed to connect to node: %v", err)
|
||||||
}
|
}
|
||||||
healthResp.Body.Close()
|
healthResp.Body.Close()
|
||||||
|
|
@ -627,11 +779,11 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
||||||
authHeader := fmt.Sprintf("Bearer %s", node.ApiKey)
|
authHeader := fmt.Sprintf("Bearer %s", node.ApiKey)
|
||||||
req.Header.Set("Authorization", authHeader)
|
req.Header.Set("Authorization", authHeader)
|
||||||
|
|
||||||
logger.Debugf("Validating API key for node %s at %s (key: %s)", node.Name, url, node.ApiKey)
|
logger.Debugf("[Node: %s] Validating API key at %s", node.Name, url)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to connect to node %s: %v", node.Address, err)
|
logger.Errorf("[Node: %s] Failed to connect: %v", node.Name, err)
|
||||||
return fmt.Errorf("failed to connect to node: %v", err)
|
return fmt.Errorf("failed to connect to node: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
@ -639,16 +791,16 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
logger.Warningf("Invalid API key for node %s (sent: %s): %s", node.Address, authHeader, string(body))
|
logger.Warningf("[Node: %s] Invalid API key: %s", node.Name, string(body))
|
||||||
return fmt.Errorf("invalid API key")
|
return fmt.Errorf("invalid API key")
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
logger.Errorf("Node %s returned status %d: %s", node.Address, resp.StatusCode, string(body))
|
logger.Errorf("[Node: %s] Returned status %d: %s", node.Name, resp.StatusCode, string(body))
|
||||||
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("API key validated successfully for node %s", node.Name)
|
logger.Debugf("[Node: %s] API key validated successfully", node.Name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -684,3 +836,82 @@ func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, e
|
||||||
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNodeLogs retrieves XRAY access logs from a node.
|
||||||
|
// Returns raw log lines as strings.
|
||||||
|
func (s *NodeService) GetNodeLogs(node *model.Node, count int, filter string) ([]string, error) {
|
||||||
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/v1/logs?count=%d", node.Address, count)
|
||||||
|
if filter != "" {
|
||||||
|
url += "&filter=" + filter
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to request node logs: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Logs []string `json:"logs"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeServiceLogs retrieves service application logs from a node (node service logs and XRAY core logs).
|
||||||
|
// Returns log lines as strings.
|
||||||
|
func (s *NodeService) GetNodeServiceLogs(node *model.Node, count int, level string) ([]string, error) {
|
||||||
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/v1/service-logs?count=%d&level=%s", node.Address, count, level)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to request node service logs: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Logs []string `json:"logs"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Logs, nil
|
||||||
|
}
|
||||||
|
|
@ -793,7 +793,8 @@ func (s *ServerService) GetXrayLogs(
|
||||||
showBlocked string,
|
showBlocked string,
|
||||||
showProxy string,
|
showProxy string,
|
||||||
freedoms []string,
|
freedoms []string,
|
||||||
blackholes []string) []LogEntry {
|
blackholes []string,
|
||||||
|
nodeId string) []LogEntry {
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Direct = iota
|
Direct = iota
|
||||||
|
|
@ -808,8 +809,70 @@ func (s *ServerService) GetXrayLogs(
|
||||||
settingService := SettingService{}
|
settingService := SettingService{}
|
||||||
multiMode, err := settingService.GetMultiNodeMode()
|
multiMode, err := settingService.GetMultiNodeMode()
|
||||||
if err == nil && multiMode {
|
if err == nil && multiMode {
|
||||||
// In multi-node mode, logs are on nodes, not locally
|
// In multi-node mode, get logs from node
|
||||||
return nil
|
if nodeId != "" {
|
||||||
|
nodeIdInt, err := strconv.Atoi(nodeId)
|
||||||
|
if err == nil {
|
||||||
|
nodeService := NodeService{}
|
||||||
|
node, err := nodeService.GetNode(nodeIdInt)
|
||||||
|
if err == nil && node != nil {
|
||||||
|
// Get raw logs from node
|
||||||
|
rawLogs, err := nodeService.GetNodeLogs(node, countInt, filter)
|
||||||
|
if err == nil {
|
||||||
|
// Parse logs into LogEntry format
|
||||||
|
for _, line := range rawLogs {
|
||||||
|
var entry LogEntry
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
if i == 0 {
|
||||||
|
if 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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no nodeId provided or node not found, return empty
|
||||||
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
pathToAccessLog, err := xray.GetAccessLogPath()
|
pathToAccessLog, err := xray.GetAccessLogPath()
|
||||||
|
|
|
||||||
|
|
@ -401,16 +401,16 @@ func (s *XrayService) restartXrayMultiMode(isForce bool) error {
|
||||||
// Marshal config to JSON
|
// Marshal config to JSON
|
||||||
configJSON, err := json.MarshalIndent(&nodeConfig, "", " ")
|
configJSON, err := json.MarshalIndent(&nodeConfig, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to marshal config for node %d: %v", node.Id, err)
|
logger.Errorf("[Node: %s] Failed to marshal config: %v", node.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to node
|
// Send to node
|
||||||
if err := s.nodeService.ApplyConfigToNode(node, configJSON); err != nil {
|
if err := s.nodeService.ApplyConfigToNode(node, configJSON); err != nil {
|
||||||
logger.Errorf("Failed to apply config to node %d (%s): %v", node.Id, node.Name, err)
|
logger.Errorf("[Node: %s] Failed to apply config: %v", node.Name, err)
|
||||||
// Continue with other nodes even if one fails
|
// Continue with other nodes even if one fails
|
||||||
} else {
|
} else {
|
||||||
logger.Infof("Successfully applied config to node %d (%s)", node.Id, node.Name)
|
logger.Infof("[Node: %s] Successfully applied config", node.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,7 @@
|
||||||
"twoFactorModalError" = "Wrong code"
|
"twoFactorModalError" = "Wrong code"
|
||||||
|
|
||||||
[pages.nodes]
|
[pages.nodes]
|
||||||
|
responseTime = "Response Time"
|
||||||
"title" = "Nodes Management"
|
"title" = "Nodes Management"
|
||||||
"addNewNode" = "Add New Node"
|
"addNewNode" = "Add New Node"
|
||||||
"addNode" = "Add Node"
|
"addNode" = "Add Node"
|
||||||
|
|
@ -611,6 +612,17 @@
|
||||||
"address" = "Address"
|
"address" = "Address"
|
||||||
"status" = "Status"
|
"status" = "Status"
|
||||||
"assignedInbounds" = "Assigned Inbounds"
|
"assignedInbounds" = "Assigned Inbounds"
|
||||||
|
"connecting" = "Establishing connection"
|
||||||
|
"generatingApiKey" = "Generating API key"
|
||||||
|
"registeringNode" = "Registering node"
|
||||||
|
"done" = "Done"
|
||||||
|
"connectionEstablished" = "Connection established"
|
||||||
|
"connectionError" = "Connection error"
|
||||||
|
"apiKeyGenerated" = "API key generated"
|
||||||
|
"generationError" = "Generation error"
|
||||||
|
"nodeRegistered" = "Node registered"
|
||||||
|
"registrationError" = "Registration error"
|
||||||
|
"nodeAddedSuccessfully" = "Node added successfully!"
|
||||||
"checkAll" = "Check All"
|
"checkAll" = "Check All"
|
||||||
"check" = "Check"
|
"check" = "Check"
|
||||||
"online" = "Online"
|
"online" = "Online"
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,7 @@
|
||||||
"twoFactorModalError" = "Неверный код"
|
"twoFactorModalError" = "Неверный код"
|
||||||
|
|
||||||
[pages.nodes]
|
[pages.nodes]
|
||||||
|
responseTime = "Время ответа"
|
||||||
"title" = "Управление нодами"
|
"title" = "Управление нодами"
|
||||||
"addNewNode" = "Добавить новую ноду"
|
"addNewNode" = "Добавить новую ноду"
|
||||||
"addNode" = "Добавить ноду"
|
"addNode" = "Добавить ноду"
|
||||||
|
|
@ -611,6 +612,17 @@
|
||||||
"address" = "Адрес"
|
"address" = "Адрес"
|
||||||
"status" = "Статус"
|
"status" = "Статус"
|
||||||
"assignedInbounds" = "Назначенные подключения"
|
"assignedInbounds" = "Назначенные подключения"
|
||||||
|
"connecting" = "Устанавливаю соединение"
|
||||||
|
"generatingApiKey" = "Генерирую API ключ"
|
||||||
|
"registeringNode" = "Регистрирую ноду"
|
||||||
|
"done" = "Готово"
|
||||||
|
"connectionEstablished" = "Соединение установлено"
|
||||||
|
"connectionError" = "Ошибка соединения"
|
||||||
|
"apiKeyGenerated" = "API ключ сгенерирован"
|
||||||
|
"generationError" = "Ошибка генерации"
|
||||||
|
"nodeRegistered" = "Нода зарегистрирована"
|
||||||
|
"registrationError" = "Ошибка регистрации"
|
||||||
|
"nodeAddedSuccessfully" = "Нода успешно добавлена!"
|
||||||
"checkAll" = "Проверить все"
|
"checkAll" = "Проверить все"
|
||||||
"check" = "Проверить"
|
"check" = "Проверить"
|
||||||
"online" = "Онлайн"
|
"online" = "Онлайн"
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,9 @@ func (s *Server) startTask() {
|
||||||
s.cron.AddJob("@every 10s", job.NewCheckNodeHealthJob())
|
s.cron.AddJob("@every 10s", job.NewCheckNodeHealthJob())
|
||||||
// Collect node statistics (traffic and online clients) every 30 seconds
|
// Collect node statistics (traffic and online clients) every 30 seconds
|
||||||
s.cron.AddJob("@every 30s", job.NewCollectNodeStatsJob())
|
s.cron.AddJob("@every 30s", job.NewCollectNodeStatsJob())
|
||||||
|
// Collect node logs and add to panel log buffer every 30 seconds
|
||||||
|
// Disabled: logs are now pushed from nodes in real-time via push-logs endpoint
|
||||||
|
// s.cron.AddJob("@every 30s", job.NewCollectNodeLogsJob())
|
||||||
|
|
||||||
// Make a traffic condition every day, 8:30
|
// Make a traffic condition every day, 8:30
|
||||||
var entry cron.EntryID
|
var entry cron.EntryID
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const (
|
||||||
MessageTypeNotification MessageType = "notification" // System notification
|
MessageTypeNotification MessageType = "notification" // System notification
|
||||||
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
||||||
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
||||||
|
MessageTypeNodes MessageType = "nodes" // Nodes list update
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message represents a WebSocket message
|
// Message represents a WebSocket message
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,11 @@ func BroadcastXrayState(state string, errorMsg string) {
|
||||||
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BroadcastNodes broadcasts nodes list update to all connected clients
|
||||||
|
func BroadcastNodes(nodes any) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
hub.Broadcast(MessageTypeNodes, nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue