edit registration steps nodes (auto setup api-key), edit loggers

This commit is contained in:
Konstantin Pichugin 2026-01-12 05:01:31 +03:00
parent a196dcddb0
commit 9263402370
24 changed files with 2244 additions and 97 deletions

View file

@ -167,9 +167,10 @@ type Node struct {
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://...")
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
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
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
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)
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)

View file

@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
@ -209,6 +210,27 @@ func addToBuffer(level string, newLog string) {
level: logLevel,
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.

View file

@ -6,9 +6,12 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"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/gin-gonic/gin"
)
@ -40,6 +43,9 @@ func (s *Server) Start() error {
// Health check endpoint (no auth required)
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 := router.Group("/api/v1")
{
@ -48,6 +54,8 @@ func (s *Server) Start() error {
api.POST("/force-reload", s.forceReload)
api.GET("/status", s.status)
api.GET("/stats", s.stats)
api.GET("/logs", s.getLogs)
api.GET("/service-logs", s.getServiceLogs)
}
s.httpServer = &http.Server{
@ -72,8 +80,8 @@ func (s *Server) Stop() error {
// authMiddleware validates API key from Authorization header.
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth for health endpoint
if c.Request.URL.Path == "/health" {
// Skip auth for health and registration endpoints
if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" {
c.Next()
return
}
@ -117,11 +125,25 @@ func (s *Server) applyConfig(c *gin.Context) {
return
}
// Validate JSON
var configJSON json.RawMessage
if err := json.Unmarshal(body, &configJSON); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
// Try to parse as JSON with optional panelUrl field
var requestData struct {
Config json.RawMessage `json:"config"`
PanelURL string `json:"panelUrl,omitempty"`
}
// First try to parse as new format with panelUrl
if err := json.Unmarshal(body, &requestData); err == nil && requestData.PanelURL != "" {
// New format: { "config": {...}, "panelUrl": "http://..." }
body = requestData.Config
// Set panel URL for log pusher
nodeLogs.SetPanelURL(requestData.PanelURL)
} else {
// Old format: just JSON config, validate it
var configJSON json.RawMessage
if err := json.Unmarshal(body, &configJSON); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
}
if err := s.xrayManager.ApplyConfig(body); err != nil {
@ -175,3 +197,107 @@ func (s *Server) stats(c *gin.Context) {
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
View 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
}

View file

@ -7,7 +7,8 @@ services:
restart: unless-stopped
environment:
# - 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:
- "8080:8080"
- "44000:44000"
@ -26,7 +27,8 @@ services:
restart: unless-stopped
environment:
# - 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:
- "8081:8080"
- "44001:44001"
@ -45,7 +47,8 @@ services:
container_name: 3x-ui-node3
restart: unless-stopped
environment:
- NODE_API_KEY=test-key
#- NODE_API_KEY=test-key
- PANEL_URL=http://192.168.0.7:2054
ports:
- "8082:8080"
- "44002:44002"

346
node/logs/pusher.go Normal file
View 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()
}
}

View file

@ -4,6 +4,7 @@ package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
@ -11,27 +12,88 @@ import (
"github.com/mhsanaei/3x-ui/v2/logger"
"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/op/go-logging"
)
func main() {
var port int
var apiKey string
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()
// 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 == "" {
apiKey = os.Getenv("NODE_API_KEY")
}
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()
server := api.NewServer(port, apiKey, xrayManager)

View file

@ -2,6 +2,7 @@
package xray
import (
"bufio"
"encoding/json"
"errors"
"fmt"
@ -9,6 +10,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@ -469,3 +471,71 @@ func (m *Manager) GetStats(reset bool) (*NodeStats, error) {
OnlineClients: onlineList,
}, 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
}

View file

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

View file

@ -2,11 +2,15 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
"github.com/gin-gonic/gin"
)
@ -37,6 +41,9 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.POST("/reload/:id", a.reloadNode)
g.POST("/reloadAll", a.reloadAllNodes)
g.GET("/status/:id", a.getNodeStatus)
g.POST("/logs/:id", a.getNodeLogs)
g.POST("/check-connection", a.checkNodeConnection) // Check node connection without API key
// push-logs endpoint moved to APIController to bypass session auth
}
// getNodes retrieves the list of all nodes.
@ -80,7 +87,7 @@ func (a *NodeController) getNode(c *gin.Context) {
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) {
node := &model.Node{}
err := c.ShouldBind(node)
@ -90,31 +97,42 @@ func (a *NodeController) addNode(c *gin.Context) {
}
// 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
err = a.nodeService.ValidateApiKey(node)
// Note: Connection check is done on frontend via /panel/node/check-connection endpoint
// to avoid CORS issues. Here we proceed directly to registration.
// Generate API key and register node
apiKey, err := a.nodeService.RegisterNode(node)
if err != nil {
logger.Errorf("API key validation failed for node %s: %v", node.Address, err)
jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err)
logger.Errorf("[Node: %s] Registration failed: %v", node.Name, err)
jsonMsg(c, "Failed to register node: "+err.Error(), err)
return
}
// Set the generated API key
node.ApiKey = apiKey
// Set default status
if node.Status == "" {
node.Status = "unknown"
}
// Save node to database
err = a.nodeService.AddNode(node)
if err != nil {
jsonMsg(c, "Failed to add node", err)
jsonMsg(c, "Failed to add node to database", err)
return
}
// Check health immediately
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.
@ -211,6 +229,9 @@ func (a *NodeController) updateNode(c *gin.Context) {
return
}
// Broadcast nodes update via WebSocket
a.broadcastNodesUpdate()
jsonMsgObj(c, "Node updated successfully", node, nil)
}
@ -228,6 +249,9 @@ func (a *NodeController) deleteNode(c *gin.Context) {
return
}
// Broadcast nodes update via WebSocket
a.broadcastNodesUpdate()
jsonMsg(c, "Node deleted successfully", nil)
}
@ -251,12 +275,20 @@ func (a *NodeController) checkNode(c *gin.Context) {
return
}
// Broadcast nodes update via WebSocket (to update status and response time)
a.broadcastNodesUpdate()
jsonMsgObj(c, "Node health check completed", node, nil)
}
// checkAllNodes checks the health of all nodes.
func (a *NodeController) checkAllNodes(c *gin.Context) {
a.nodeService.CheckAllNodesHealth()
// Broadcast nodes update after health check (with delay to allow all checks to complete)
go func() {
time.Sleep(3 * time.Second) // Wait for health checks to complete
a.broadcastNodesUpdate()
}()
jsonMsg(c, "Health check initiated for all nodes", nil)
}
@ -317,3 +349,213 @@ func (a *NodeController) reloadAllNodes(c *gin.Context) {
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)
}

View file

@ -237,7 +237,8 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
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)
}

View file

@ -392,6 +392,15 @@
</a-icon>
</template>
<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-input-group compact>
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
@ -834,10 +843,14 @@
visible: false,
logs: [],
rows: 20,
filter: '',
showDirect: true,
showBlocked: true,
showProxy: true,
loading: false,
multiNodeMode: false,
nodes: [],
nodeId: '',
show(logs) {
this.visible = true;
this.logs = logs;
@ -944,11 +957,31 @@
const msg = await HttpUtil.post("/panel/setting/all");
if (msg && msg.success && msg.obj) {
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) {
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() {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
@ -1075,12 +1108,45 @@
logModal.loading = false;
},
async openXrayLogs() {
xraylogModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
if (!msg.success) {
return;
// Ensure multi-node mode is loaded and nodes are available
if (this.multiNodeMode && xraylogModal.nodes.length === 0) {
await this.loadNodesForLogs();
}
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);
xraylogModal.loading = false;
},
@ -1165,6 +1231,13 @@
}, 2000);
},
},
watch: {
'xraylogModal.visible'(newVal) {
if (newVal && this.multiNodeMode && xraylogModal.nodes.length === 0) {
this.loadNodesForLogs();
}
}
},
async mounted() {
if (window.location.protocol !== "https:") {
this.showAlert = true;

View file

@ -1,31 +1,73 @@
{{define "modals/nodeModal"}}
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600">
<a-form layout="vertical">
<a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
<a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
<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 label='{{ i18n "pages.nodes.nodeApiKey" }}'>
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder='{{ i18n "pages.nodes.enterApiKey" }}'></a-input-password>
</a-form-item>
</a-form>
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600"
:confirm-loading="nodeModal.registering" :ok-button-props="{ disabled: nodeModal.registering }">
<div v-if="!nodeModal.registering && !nodeModal.showProgress">
<a-form layout="vertical">
<a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
<a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
<a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<!-- API key is now auto-generated during registration, no need for user input -->
</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>
<script>
const nodeModal = window.nodeModal = {
visible: false,
title: '',
okText: 'OK',
registering: false,
showProgress: false,
currentStep: 0,
steps: {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
},
formData: {
name: '',
address: '',
port: 8080,
apiKey: ''
port: 8080
// apiKey is now auto-generated during registration
},
ok() {
// Валидация полей - используем nodeModal напрямую для правильного контекста
@ -47,14 +89,7 @@
return;
}
if (!nodeModal.formData.apiKey || !nodeModal.formData.apiKey.trim()) {
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;
}
// API key is now auto-generated during registration, no validation needed
// Если все поля заполнены, формируем полный адрес с портом
const dataToSend = { ...nodeModal.formData };
@ -80,19 +115,51 @@
delete dataToSend.port;
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) {
nodeModal.confirm(dataToSend);
}
nodeModal.visible = false;
},
cancel() {
this.visible = false;
this.resetProgress();
},
show({ title = '', okText = 'OK', node = null, confirm = (data) => { }, isEdit = false }) {
this.title = title;
this.okText = okText;
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) {
// Извлекаем адрес и порт из полного URL
@ -119,15 +186,15 @@
this.formData = {
name: node.name || '',
address: address,
port: port,
apiKey: node.apiKey || ''
port: port
// apiKey is not shown in edit mode (it's managed by the system)
};
} else {
this.formData = {
name: '',
address: '',
port: 8080,
apiKey: ''
port: 8080
// apiKey is auto-generated during registration
};
}
@ -135,6 +202,18 @@
},
close() {
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'
};
}
};

View file

@ -58,6 +58,15 @@
[[ node.status || 'unknown' ]]
</a-tag>
</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 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' }">
@ -135,6 +144,11 @@
align: 'center',
width: 80,
scopedSlots: { customRender: 'status' },
}, {
title: '{{ i18n "pages.nodes.responseTime" }}',
align: 'center',
width: 100,
scopedSlots: { customRender: 'responseTime' },
}, {
title: '{{ i18n "pages.nodes.assignedInbounds" }}',
align: 'left',
@ -182,6 +196,7 @@
reloadingAll: false,
editingNodeId: null,
editingNodeName: '',
pollInterval: null,
},
methods: {
async loadNodes() {
@ -194,6 +209,7 @@
name: node.name || '',
address: node.address || '',
status: node.status || 'unknown',
responseTime: node.responseTime || 0,
inbounds: node.inbounds || []
}));
}
@ -427,26 +443,169 @@
}
},
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 {
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);
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();
if (window.nodeModal) {
window.nodeModal.close();
}
} 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) {
console.error(`Failed to ${isEdit ? 'update' : 'add'} node:`, e);
app.$message.error(isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}');
console.error('Failed to add node:', e);
// Определяем на каком шаге произошла ошибка
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() {
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();
}
}
});

View file

@ -2,8 +2,12 @@
package job
import (
"sync"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
)
// 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))
// Use a wait group to wait for all health checks to complete
var wg sync.WaitGroup
for _, node := range nodes {
n := node // Capture loop variable
wg.Add(1)
go func() {
defer wg.Done()
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 {
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)
}()
}

View 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))
}
}

View file

@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/xray"
)
@ -47,6 +48,71 @@ func (s *NodeService) AddNode(node *model.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(&registerResp); 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.
// Only updates fields that are provided (non-empty for strings, non-zero for integers).
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
// 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 {
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 {
updates["last_check"] = node.LastCheck
}
@ -116,18 +189,27 @@ func (s *NodeService) DeleteNode(id int) 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 {
status, err := s.CheckNodeStatus(node)
status, responseTime, err := s.CheckNodeStatus(node)
if err != nil {
node.Status = "error"
node.ResponseTime = 0 // Set to 0 on error
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
}
node.Status = status
node.ResponseTime = responseTime
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.
@ -172,24 +254,30 @@ func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration)
}, nil
}
// CheckNodeStatus performs a health check on a given node.
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
// CheckNodeStatus performs a health check on a given node and measures response time.
// 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)
if err != nil {
return "error", err
return "error", 0, err
}
url := fmt.Sprintf("%s/health", node.Address)
// Measure response time
startTime := time.Now()
resp, err := client.Get(url)
responseTime := time.Since(startTime).Milliseconds()
if err != nil {
return "offline", err
return "offline", 0, err
}
defer resp.Body.Close()
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.
@ -383,10 +471,10 @@ func (s *NodeService) CollectNodeStats() error {
strings.Contains(errMsg, "status code 404") ||
strings.Contains(errMsg, "status code 500") {
// 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 {
// 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
}
@ -481,8 +569,24 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) 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)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(xrayConfig))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON))
if err != nil {
return err
}
@ -504,6 +608,54 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err
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.
func (s *NodeService) ReloadNode(node *model.Node) error {
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)
healthResp, err := client.Get(healthURL)
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)
}
healthResp.Body.Close()
@ -627,11 +779,11 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
authHeader := fmt.Sprintf("Bearer %s", node.ApiKey)
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)
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)
}
defer resp.Body.Close()
@ -639,16 +791,16 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
body, _ := io.ReadAll(resp.Body)
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")
}
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))
}
logger.Debugf("API key validated successfully for node %s", node.Name)
logger.Debugf("[Node: %s] API key validated successfully", node.Name)
return nil
}
@ -684,3 +836,82 @@ func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, e
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
}

View file

@ -793,7 +793,8 @@ func (s *ServerService) GetXrayLogs(
showBlocked string,
showProxy string,
freedoms []string,
blackholes []string) []LogEntry {
blackholes []string,
nodeId string) []LogEntry {
const (
Direct = iota
@ -808,8 +809,70 @@ func (s *ServerService) GetXrayLogs(
settingService := SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err == nil && multiMode {
// In multi-node mode, logs are on nodes, not locally
return nil
// In multi-node mode, get logs from node
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()

View file

@ -401,16 +401,16 @@ func (s *XrayService) restartXrayMultiMode(isForce bool) error {
// Marshal config to JSON
configJSON, err := json.MarshalIndent(&nodeConfig, "", " ")
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
}
// Send to node
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
} else {
logger.Infof("Successfully applied config to node %d (%s)", node.Id, node.Name)
logger.Infof("[Node: %s] Successfully applied config", node.Name)
}
}

View file

@ -592,6 +592,7 @@
"twoFactorModalError" = "Wrong code"
[pages.nodes]
responseTime = "Response Time"
"title" = "Nodes Management"
"addNewNode" = "Add New Node"
"addNode" = "Add Node"
@ -611,6 +612,17 @@
"address" = "Address"
"status" = "Status"
"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"
"check" = "Check"
"online" = "Online"

View file

@ -592,6 +592,7 @@
"twoFactorModalError" = "Неверный код"
[pages.nodes]
responseTime = "Время ответа"
"title" = "Управление нодами"
"addNewNode" = "Добавить новую ноду"
"addNode" = "Добавить ноду"
@ -611,6 +612,17 @@
"address" = "Адрес"
"status" = "Статус"
"assignedInbounds" = "Назначенные подключения"
"connecting" = "Устанавливаю соединение"
"generatingApiKey" = "Генерирую API ключ"
"registeringNode" = "Регистрирую ноду"
"done" = "Готово"
"connectionEstablished" = "Соединение установлено"
"connectionError" = "Ошибка соединения"
"apiKeyGenerated" = "API ключ сгенерирован"
"generationError" = "Ошибка генерации"
"nodeRegistered" = "Нода зарегистрирована"
"registrationError" = "Ошибка регистрации"
"nodeAddedSuccessfully" = "Нода успешно добавлена!"
"checkAll" = "Проверить все"
"check" = "Проверить"
"online" = "Онлайн"

View file

@ -368,6 +368,9 @@ func (s *Server) startTask() {
s.cron.AddJob("@every 10s", job.NewCheckNodeHealthJob())
// Collect node statistics (traffic and online clients) every 30 seconds
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
var entry cron.EntryID

View file

@ -22,6 +22,7 @@ const (
MessageTypeNotification MessageType = "notification" // System notification
MessageTypeXrayState MessageType = "xray_state" // Xray state change
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
MessageTypeNodes MessageType = "nodes" // Nodes list update
)
// Message represents a WebSocket message

View file

@ -80,3 +80,11 @@ func BroadcastXrayState(state string, errorMsg string) {
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)
}
}