This commit is contained in:
konstpic 2026-01-11 22:30:16 +03:00 committed by GitHub
commit 550a35d776
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 4379 additions and 165 deletions

View file

@ -38,6 +38,8 @@ func initModels() error {
&model.InboundClientIps{}, &model.InboundClientIps{},
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.Node{},
&model.InboundNodeMapping{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {

View file

@ -53,6 +53,8 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
NodeId *int `json:"nodeId,omitempty" form:"-" gorm:"-"` // Node ID (not stored in Inbound table, from mapping) - DEPRECATED: kept only for backward compatibility with old clients, use NodeIds instead
NodeIds []int `json:"nodeIds,omitempty" form:"-" gorm:"-"` // Node IDs array (not stored in Inbound table, from mapping) - use this for multi-node support
} }
// OutboundTraffics tracks traffic statistics for Xray outbound connections. // OutboundTraffics tracks traffic statistics for Xray outbound connections.
@ -122,3 +124,22 @@ type Client struct {
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
} }
// Node represents a worker node in multi-node architecture.
type Node struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique 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")
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
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
}
// InboundNodeMapping maps inbounds to nodes in multi-node mode.
type InboundNodeMapping struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID
NodeId int `json:"nodeId" form:"nodeId" gorm:"uniqueIndex:idx_inbound_node"` // Node ID
}

View file

@ -13,4 +13,4 @@ services:
XUI_ENABLE_FAIL2BAN: "true" XUI_ENABLE_FAIL2BAN: "true"
tty: true tty: true
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped

9
go.mod
View file

@ -1,5 +1,9 @@
module github.com/mhsanaei/3x-ui/v2 module github.com/mhsanaei/3x-ui/v2
// Local development - use local files instead of GitHub
// These replace directives ensure we use local code during development
// Remove these when changes are pushed to GitHub
go 1.25.5 go 1.25.5
require ( require (
@ -101,3 +105,8 @@ require (
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect
) )
// Local development - use local files instead of GitHub
// This ensures we use local code during development
// Remove this when changes are pushed to GitHub
replace github.com/mhsanaei/3x-ui/v2 => ./

View file

@ -69,12 +69,19 @@ func initDefaultBackend() logging.Backend {
includeTime = true includeTime = true
} else { } else {
// Unix-like: Try syslog, fallback to stderr // Unix-like: Try syslog, fallback to stderr
if syslogBackend, err := logging.NewSyslogBackend(""); err != nil { // Try syslog with "x-ui" tag first
fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err) if syslogBackend, err := logging.NewSyslogBackend("x-ui"); err == nil {
backend = logging.NewLogBackend(os.Stderr, "", 0)
includeTime = os.Getppid() > 0
} else {
backend = syslogBackend backend = syslogBackend
} else {
// Try with empty tag as fallback
if syslogBackend2, err2 := logging.NewSyslogBackend(""); err2 == nil {
backend = syslogBackend2
} else {
// Syslog unavailable - use stderr (normal in containers/Docker)
// In containers, syslog is often not configured - this is normal and expected
backend = logging.NewLogBackend(os.Stderr, "", 0)
includeTime = os.Getppid() > 0
}
} }
} }

124
node/Dockerfile Normal file
View file

@ -0,0 +1,124 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /build
# Install build dependencies
RUN apk --no-cache add curl unzip
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build node service
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o node-service ./node/main.go
# Download XRAY Core based on target architecture
# TARGETARCH is automatically set by Docker BuildKit
ARG TARGETARCH=amd64
ARG TARGETOS=linux
RUN mkdir -p bin && \
cd bin && \
case ${TARGETARCH} in \
amd64) \
ARCH="64" \
FNAME="amd64" \
;; \
arm64) \
ARCH="arm64-v8a" \
FNAME="arm64" \
;; \
arm) \
ARCH="arm32-v7a" \
FNAME="arm32" \
;; \
armv6) \
ARCH="arm32-v6" \
FNAME="armv6" \
;; \
386) \
ARCH="32" \
FNAME="i386" \
;; \
*) \
ARCH="64" \
FNAME="amd64" \
;; \
esac && \
echo "Downloading Xray for ${TARGETARCH} (ARCH=${ARCH}, FNAME=${FNAME})" && \
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip" && \
echo "Unzipping..." && \
unzip -q "Xray-linux-${ARCH}.zip" && \
echo "Files after unzip:" && \
ls -la && \
echo "Removing zip and old data files..." && \
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat && \
echo "Renaming xray to xray-linux-${FNAME}..." && \
mv xray "xray-linux-${FNAME}" && \
chmod +x "xray-linux-${FNAME}" && \
echo "Verifying xray binary:" && \
ls -lh "xray-linux-${FNAME}" && \
test -f "xray-linux-${FNAME}" && echo "✓ xray-linux-${FNAME} exists" && \
echo "Downloading geo files..." && \
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat && \
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat && \
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat && \
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat && \
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat && \
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat && \
echo "Final files in bin:" && \
ls -lah && \
echo "File sizes:" && \
du -h * && \
cd .. && \
echo "Verifying files in /build/bin:" && \
ls -lah /build/bin/
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/node-service .
# Copy XRAY binary and data files
# Use wildcard to copy all files from bin directory
COPY --from=builder /build/bin/ ./bin/
# Verify files were copied and make executable
RUN echo "Contents of /app/bin after COPY:" && \
ls -la ./bin/ && \
echo "Looking for xray binary..." && \
if [ -f ./bin/xray-linux-amd64 ]; then \
chmod +x ./bin/xray-linux-amd64 && \
echo "✓ Found and made executable: xray-linux-amd64"; \
elif [ -f ./bin/xray ]; then \
chmod +x ./bin/xray && \
mv ./bin/xray ./bin/xray-linux-amd64 && \
echo "✓ Found xray, renamed to xray-linux-amd64"; \
else \
echo "✗ ERROR: No xray binary found!" && \
echo "All files in bin directory:" && \
find ./bin -type f -o -type l && \
exit 1; \
fi
# Create directories for config and logs
RUN mkdir -p /app/config /app/logs
# Set environment variables for paths
ENV XUI_BIN_FOLDER=/app/bin
ENV XUI_LOG_FOLDER=/app/logs
# Expose API port
EXPOSE 8080
# Run node service
# The API key will be read from NODE_API_KEY environment variable
CMD ["./node-service", "-port", "8080"]

79
node/README.md Normal file
View file

@ -0,0 +1,79 @@
# 3x-ui Node Service
Node service (worker) for 3x-ui multi-node architecture.
## Description
This service runs on separate servers and manages XRAY Core instances. The 3x-ui panel (master) sends configurations to nodes via REST API.
## Features
- REST API for XRAY Core management
- Apply configurations from the panel
- Reload XRAY without stopping the container
- Status and health checks
## API Endpoints
### `GET /health`
Health check endpoint (no authentication required)
### `POST /api/v1/apply`
Apply new XRAY configuration
- **Headers**: `Authorization: Bearer <api-key>`
- **Body**: XRAY JSON configuration
### `POST /api/v1/reload`
Reload XRAY
- **Headers**: `Authorization: Bearer <api-key>`
### `POST /api/v1/force-reload`
Force reload XRAY (stops and restarts)
- **Headers**: `Authorization: Bearer <api-key>`
### `GET /api/v1/status`
Get XRAY status
- **Headers**: `Authorization: Bearer <api-key>`
### `GET /api/v1/stats`
Get traffic statistics and online clients
- **Headers**: `Authorization: Bearer <api-key>`
- **Query Parameters**: `reset=true` to reset statistics after reading
## Running
### Docker Compose
```bash
cd node
NODE_API_KEY=your-secure-api-key docker-compose up -d --build
```
**Note:** XRAY Core is automatically downloaded during Docker image build for your architecture. Docker BuildKit automatically detects the host architecture. To explicitly specify the architecture, use:
```bash
DOCKER_BUILDKIT=1 docker build --build-arg TARGETARCH=arm64 -t 3x-ui-node -f node/Dockerfile ..
```
### Manual
```bash
go run node/main.go -port 8080 -api-key your-secure-api-key
```
## Environment Variables
- `NODE_API_KEY` - API key for authentication (required)
## Structure
```
node/
├── main.go # Entry point
├── api/
│ └── server.go # REST API server
├── xray/
│ └── manager.go # XRAY process management
├── Dockerfile # Docker image
└── docker-compose.yml
```

177
node/api/server.go Normal file
View file

@ -0,0 +1,177 @@
// Package api provides REST API endpoints for the node service.
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/node/xray"
"github.com/gin-gonic/gin"
)
// Server provides REST API for managing the node.
type Server struct {
port int
apiKey string
xrayManager *xray.Manager
httpServer *http.Server
}
// NewServer creates a new API server instance.
func NewServer(port int, apiKey string, xrayManager *xray.Manager) *Server {
return &Server{
port: port,
apiKey: apiKey,
xrayManager: xrayManager,
}
}
// Start starts the HTTP server.
func (s *Server) Start() error {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
router.Use(s.authMiddleware())
// Health check endpoint (no auth required)
router.GET("/health", s.health)
// API endpoints (require auth)
api := router.Group("/api/v1")
{
api.POST("/apply-config", s.applyConfig)
api.POST("/reload", s.reload)
api.POST("/force-reload", s.forceReload)
api.GET("/status", s.status)
api.GET("/stats", s.stats)
}
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
logger.Infof("API server listening on port %d", s.port)
return s.httpServer.ListenAndServe()
}
// Stop stops the HTTP server.
func (s *Server) Stop() error {
if s.httpServer == nil {
return nil
}
return s.httpServer.Close()
}
// authMiddleware validates API key from Authorization header.
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth for health endpoint
if c.Request.URL.Path == "/health" {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
c.Abort()
return
}
// Support both "Bearer <key>" and direct key
apiKey := authHeader
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
apiKey = authHeader[7:]
}
if apiKey != s.apiKey {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
c.Abort()
return
}
c.Next()
}
}
// health returns the health status of the node.
func (s *Server) health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "3x-ui-node",
})
}
// applyConfig applies a new XRAY configuration.
func (s *Server) applyConfig(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
// Validate JSON
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 {
logger.Errorf("Failed to apply config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Configuration applied successfully"})
}
// reload reloads XRAY configuration.
func (s *Server) reload(c *gin.Context) {
if err := s.xrayManager.Reload(); err != nil {
logger.Errorf("Failed to reload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "XRAY reloaded successfully"})
}
// forceReload forcefully reloads XRAY even if it's hung or not running.
func (s *Server) forceReload(c *gin.Context) {
if err := s.xrayManager.ForceReload(); err != nil {
logger.Errorf("Failed to force reload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "XRAY force reloaded successfully"})
}
// status returns the current status of XRAY.
func (s *Server) status(c *gin.Context) {
status := s.xrayManager.GetStatus()
c.JSON(http.StatusOK, status)
}
// stats returns traffic and online clients statistics from XRAY.
func (s *Server) stats(c *gin.Context) {
// Get reset parameter (default: false)
reset := c.DefaultQuery("reset", "false") == "true"
stats, err := s.xrayManager.GetStats(reset)
if err != nil {
logger.Errorf("Failed to get stats: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

24
node/docker-compose.yml Normal file
View file

@ -0,0 +1,24 @@
services:
node:
build:
context: ..
dockerfile: node/Dockerfile
container_name: 3x-ui-node
restart: unless-stopped
environment:
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
- NODE_API_KEY=test-key
ports:
- "8080:8080"
- "44000:44000"
volumes:
- ./bin/config.json:/app/bin/config.json
- ./logs:/app/logs
# Note: config.json is mounted directly for persistence
# If the file doesn't exist, it will be created when XRAY config is first applied
networks:
- xray-network
networks:
xray-network:
driver: bridge

52
node/main.go Normal file
View file

@ -0,0 +1,52 @@
// Package main is the entry point for the 3x-ui node service (worker).
// This service runs XRAY Core and provides a REST API for the master panel to manage it.
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/node/api"
"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.Parse()
// Check environment variable if flag is not provided
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")
}
logger.InitLogger(logging.INFO)
xrayManager := xray.NewManager()
server := api.NewServer(port, apiKey, xrayManager)
log.Printf("Starting 3x-ui Node Service on port %d", port)
if err := server.Start(); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("Shutting down...")
xrayManager.Stop()
server.Stop()
log.Println("Shutdown complete")
}

471
node/xray/manager.go Normal file
View file

@ -0,0 +1,471 @@
// Package xray provides XRAY Core management for the node service.
package xray
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/json_util"
"github.com/mhsanaei/3x-ui/v2/xray"
)
// NodeStats represents traffic and online clients statistics from a node.
type NodeStats struct {
Traffic []*xray.Traffic `json:"traffic"`
ClientTraffic []*xray.ClientTraffic `json:"clientTraffic"`
OnlineClients []string `json:"onlineClients"`
}
// Manager manages the XRAY Core process lifecycle.
type Manager struct {
process *xray.Process
lock sync.Mutex
config *xray.Config
}
// NewManager creates a new XRAY manager instance.
func NewManager() *Manager {
m := &Manager{}
// Download geo files if missing
m.downloadGeoFiles()
// Try to load config from file on startup
m.LoadConfigFromFile()
return m
}
// downloadGeoFiles downloads geo data files if they are missing.
// These files are required for routing rules that use geoip/geosite matching.
func (m *Manager) downloadGeoFiles() {
// Possible bin folder paths (in order of priority)
binPaths := []string{
"bin",
"/app/bin",
"./bin",
}
var binPath string
for _, path := range binPaths {
if _, err := os.Stat(path); err == nil {
binPath = path
break
}
}
if binPath == "" {
logger.Debug("No bin folder found, skipping geo files download")
return
}
// List of geo files to download
geoFiles := []struct {
URL string
FileName string
}{
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
}
downloadFile := func(url, destPath string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %d", resp.StatusCode)
}
file, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
for _, file := range geoFiles {
destPath := filepath.Join(binPath, file.FileName)
// Check if file already exists
if _, err := os.Stat(destPath); err == nil {
logger.Debugf("Geo file %s already exists, skipping download", file.FileName)
continue
}
logger.Infof("Downloading geo file: %s", file.FileName)
if err := downloadFile(file.URL, destPath); err != nil {
logger.Warningf("Failed to download %s: %v", file.FileName, err)
} else {
logger.Infof("Successfully downloaded %s", file.FileName)
}
}
}
// LoadConfigFromFile attempts to load XRAY configuration from config.json file.
// It checks multiple possible locations: bin/config.json, config/config.json, and ./config.json
func (m *Manager) LoadConfigFromFile() error {
// Possible config file paths (in order of priority)
configPaths := []string{
"bin/config.json",
"config/config.json",
"./config.json",
"/app/bin/config.json",
"/app/config/config.json",
}
var configData []byte
var configPath string
// Try each path until we find a valid config file
for _, path := range configPaths {
if _, statErr := os.Stat(path); statErr == nil {
var readErr error
configData, readErr = os.ReadFile(path)
if readErr == nil {
configPath = path
break
}
}
}
// If no config file found, that's okay - node will wait for config from panel
if configPath == "" {
logger.Debug("No config.json found, node will wait for configuration from panel")
return nil
}
// Validate JSON
var configJSON json.RawMessage
if err := json.Unmarshal(configData, &configJSON); err != nil {
logger.Warningf("Config file %s contains invalid JSON: %v", configPath, err)
return fmt.Errorf("invalid JSON in config file: %w", err)
}
// Parse full config
var config xray.Config
if err := json.Unmarshal(configData, &config); err != nil {
logger.Warningf("Failed to parse config from %s: %v", configPath, err)
return fmt.Errorf("failed to parse config: %w", err)
}
// Check if API inbound exists, if not add it
hasAPIInbound := false
for _, inbound := range config.InboundConfigs {
if inbound.Tag == "api" {
hasAPIInbound = true
break
}
}
// If no API inbound found, add a default one
if !hasAPIInbound {
logger.Debug("No API inbound found in config, adding default API inbound")
apiInbound := xray.InboundConfig{
Tag: "api",
Port: 62789, // Default API port
Protocol: "tunnel",
Listen: json_util.RawMessage(`"127.0.0.1"`),
Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`),
}
// Add API inbound at the beginning
config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...)
// Update configData with the new inbound
configData, _ = json.MarshalIndent(&config, "", " ")
}
// Check if config has inbounds (after adding API inbound)
if len(config.InboundConfigs) == 0 {
logger.Debug("Config file found but no inbounds configured, skipping XRAY start")
return nil
}
// Apply the loaded config (this will start XRAY)
logger.Infof("Loading XRAY configuration from %s", configPath)
if err := m.ApplyConfig(configData); err != nil {
logger.Errorf("Failed to apply config from file: %v", err)
return fmt.Errorf("failed to apply config: %w", err)
}
logger.Info("XRAY started successfully from config file")
return nil
}
// IsRunning returns true if XRAY is currently running.
func (m *Manager) IsRunning() bool {
m.lock.Lock()
defer m.lock.Unlock()
return m.process != nil && m.process.IsRunning()
}
// GetStatus returns the current status of XRAY.
func (m *Manager) GetStatus() map[string]interface{} {
m.lock.Lock()
defer m.lock.Unlock()
status := map[string]interface{}{
"running": m.process != nil && m.process.IsRunning(),
"version": "Unknown",
"uptime": 0,
}
if m.process != nil && m.process.IsRunning() {
status["version"] = m.process.GetVersion()
status["uptime"] = m.process.GetUptime()
}
return status
}
// ApplyConfig applies a new XRAY configuration and restarts if needed.
func (m *Manager) ApplyConfig(configJSON []byte) error {
m.lock.Lock()
defer m.lock.Unlock()
var newConfig xray.Config
if err := json.Unmarshal(configJSON, &newConfig); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
// If XRAY is running and config is the same, skip restart
if m.process != nil && m.process.IsRunning() {
oldConfig := m.process.GetConfig()
if oldConfig != nil && oldConfig.Equals(&newConfig) {
logger.Info("Config unchanged, skipping restart")
return nil
}
// Stop existing process
if err := m.process.Stop(); err != nil {
logger.Warningf("Failed to stop existing XRAY: %v", err)
}
}
// Start new process with new config
m.config = &newConfig
m.process = xray.NewProcess(&newConfig)
if err := m.process.Start(); err != nil {
return fmt.Errorf("failed to start XRAY: %w", err)
}
logger.Info("XRAY configuration applied successfully")
return nil
}
// Reload reloads XRAY configuration without full restart (if supported).
// Falls back to restart if reload is not available.
func (m *Manager) Reload() error {
m.lock.Lock()
defer m.lock.Unlock()
if m.process == nil || !m.process.IsRunning() {
return errors.New("XRAY is not running")
}
// XRAY doesn't support hot reload, so we need to restart
// Save current config
if m.config == nil {
return errors.New("no config to reload")
}
// Stop and restart
if err := m.process.Stop(); err != nil {
return fmt.Errorf("failed to stop XRAY: %w", err)
}
m.process = xray.NewProcess(m.config)
if err := m.process.Start(); err != nil {
return fmt.Errorf("failed to restart XRAY: %w", err)
}
logger.Info("XRAY reloaded successfully")
return nil
}
// ForceReload forcefully reloads XRAY even if it's not running or hung.
// It stops XRAY if running, loads config from file if available, and restarts.
func (m *Manager) ForceReload() error {
m.lock.Lock()
defer m.lock.Unlock()
// Stop XRAY if it's running (even if hung)
if m.process != nil {
// Try to stop gracefully, but don't fail if it's hung
_ = m.process.Stop()
// Give it a moment to stop
time.Sleep(500 * time.Millisecond)
// Force kill if still running
if m.process.IsRunning() {
logger.Warning("XRAY process appears hung, forcing stop")
// Process will be cleaned up by finalizer or on next start
}
m.process = nil
}
// Try to load config from file first (if available)
configPaths := []string{
"bin/config.json",
"config/config.json",
"./config.json",
"/app/bin/config.json",
"/app/config/config.json",
}
var configData []byte
var configPath string
// Find config file
for _, path := range configPaths {
if _, statErr := os.Stat(path); statErr == nil {
var readErr error
configData, readErr = os.ReadFile(path)
if readErr == nil {
configPath = path
break
}
}
}
// If config file found, try to use it
if configPath != "" {
var config xray.Config
if err := json.Unmarshal(configData, &config); err == nil {
// Check if config has inbounds
if len(config.InboundConfigs) > 0 {
// Check if API inbound exists
hasAPIInbound := false
for _, inbound := range config.InboundConfigs {
if inbound.Tag == "api" {
hasAPIInbound = true
break
}
}
// Add API inbound if missing
if !hasAPIInbound {
apiInbound := xray.InboundConfig{
Tag: "api",
Port: 62789,
Protocol: "tunnel",
Listen: json_util.RawMessage(`"127.0.0.1"`),
Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`),
}
config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...)
configData, _ = json.MarshalIndent(&config, "", " ")
}
// Apply config from file
m.config = &config
m.process = xray.NewProcess(&config)
if err := m.process.Start(); err == nil {
logger.Infof("XRAY force reloaded successfully from config file %s", configPath)
return nil
}
}
}
// If loading from file failed, continue with saved config
}
// If no config file, try to use saved config
if m.config == nil {
return errors.New("no config available to reload")
}
// Restart with saved config
m.process = xray.NewProcess(m.config)
if err := m.process.Start(); err != nil {
return fmt.Errorf("failed to restart XRAY: %w", err)
}
logger.Info("XRAY force reloaded successfully")
return nil
}
// Stop stops the XRAY process.
func (m *Manager) Stop() error {
m.lock.Lock()
defer m.lock.Unlock()
if m.process == nil || !m.process.IsRunning() {
return nil
}
return m.process.Stop()
}
// GetStats returns traffic and online clients statistics from XRAY.
func (m *Manager) GetStats(reset bool) (*NodeStats, error) {
m.lock.Lock()
defer m.lock.Unlock()
if m.process == nil || !m.process.IsRunning() {
return nil, errors.New("XRAY is not running")
}
// Get API port from process
apiPort := m.process.GetAPIPort()
if apiPort == 0 {
return nil, errors.New("XRAY API port is not available")
}
// Create XrayAPI instance and initialize
xrayAPI := &xray.XrayAPI{}
if err := xrayAPI.Init(apiPort); err != nil {
return nil, fmt.Errorf("failed to initialize XrayAPI: %w", err)
}
defer xrayAPI.Close()
// Get traffic statistics
traffics, clientTraffics, err := xrayAPI.GetTraffic(reset)
if err != nil {
return nil, fmt.Errorf("failed to get traffic: %w", err)
}
// Get online clients from process
onlineClients := m.process.GetOnlineClients()
// Also check online clients from traffic (clients with traffic > 0)
onlineFromTraffic := make(map[string]bool)
for _, ct := range clientTraffics {
if ct.Up+ct.Down > 0 {
onlineFromTraffic[ct.Email] = true
}
}
// Merge online clients
onlineSet := make(map[string]bool)
for _, email := range onlineClients {
onlineSet[email] = true
}
for email := range onlineFromTraffic {
onlineSet[email] = true
}
onlineList := make([]string, 0, len(onlineSet))
for email := range onlineSet {
onlineList = append(onlineList, email)
}
return &NodeStats{
Traffic: traffics,
ClientTraffic: clientTraffics,
OnlineClients: onlineList,
}, nil
}

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
service "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -40,6 +41,8 @@ func NewSUBController(
subTitle string, subTitle string,
) *SUBController { ) *SUBController {
sub := NewSubService(showInfo, rModel) sub := NewSubService(showInfo, rModel)
// Initialize NodeService for multi-node support
sub.nodeService = service.NodeService{}
a := &SUBController{ a := &SUBController{
subTitle: subTitle, subTitle: subTitle,
subPath: subPath, subPath: subPath,

View file

@ -28,6 +28,7 @@ type SubService struct {
datepicker string datepicker string
inboundService service.InboundService inboundService service.InboundService
settingService service.SettingService settingService service.SettingService
nodeService service.NodeService
} }
// NewSubService creates a new subscription service with the given configuration. // NewSubService creates a new subscription service with the given configuration.
@ -77,7 +78,14 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
for _, client := range clients { for _, client := range clients {
if client.Enable && client.SubID == subId { if client.Enable && client.SubID == subId {
link := s.getLink(inbound, client.Email) link := s.getLink(inbound, client.Email)
result = append(result, link) // Split link by newline to handle multiple links (for multiple nodes)
linkLines := strings.Split(link, "\n")
for _, linkLine := range linkLines {
linkLine = strings.TrimSpace(linkLine)
if linkLine != "" {
result = append(result, linkLine)
}
}
ct := s.getClientTraffics(inbound.ClientStats, client.Email) ct := s.getClientTraffics(inbound.ClientStats, client.Email)
clientTraffics = append(clientTraffics, ct) clientTraffics = append(clientTraffics, ct)
if ct.LastOnline > lastOnline { if ct.LastOnline > lastOnline {
@ -179,78 +187,99 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.VMESS { if inbound.Protocol != model.VMESS {
return "" return ""
} }
var address string
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { // Get all nodes for this inbound
address = s.address var nodeAddresses []string
} else { multiMode, _ := s.settingService.GetMultiNodeMode()
address = inbound.Listen if multiMode {
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
if err == nil && len(nodes) > 0 {
// Extract addresses from all nodes
for _, node := range nodes {
nodeAddr := s.extractNodeHost(node.Address)
if nodeAddr != "" {
nodeAddresses = append(nodeAddresses, nodeAddr)
}
}
}
} }
obj := map[string]any{
// Fallback to default logic if no nodes found
var defaultAddress string
if len(nodeAddresses) == 0 {
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
defaultAddress = s.address
} else {
defaultAddress = inbound.Listen
}
nodeAddresses = []string{defaultAddress}
}
// Base object template (address will be set per node)
baseObj := map[string]any{
"v": "2", "v": "2",
"add": address,
"port": inbound.Port, "port": inbound.Port,
"type": "none", "type": "none",
} }
var stream map[string]any var stream map[string]any
json.Unmarshal([]byte(inbound.StreamSettings), &stream) json.Unmarshal([]byte(inbound.StreamSettings), &stream)
network, _ := stream["network"].(string) network, _ := stream["network"].(string)
obj["net"] = network baseObj["net"] = network
switch network { switch network {
case "tcp": case "tcp":
tcp, _ := stream["tcpSettings"].(map[string]any) tcp, _ := stream["tcpSettings"].(map[string]any)
header, _ := tcp["header"].(map[string]any) header, _ := tcp["header"].(map[string]any)
typeStr, _ := header["type"].(string) typeStr, _ := header["type"].(string)
obj["type"] = typeStr baseObj["type"] = typeStr
if typeStr == "http" { if typeStr == "http" {
request := header["request"].(map[string]any) request := header["request"].(map[string]any)
requestPath, _ := request["path"].([]any) requestPath, _ := request["path"].([]any)
obj["path"] = requestPath[0].(string) baseObj["path"] = requestPath[0].(string)
headers, _ := request["headers"].(map[string]any) headers, _ := request["headers"].(map[string]any)
obj["host"] = searchHost(headers) baseObj["host"] = searchHost(headers)
} }
case "kcp": case "kcp":
kcp, _ := stream["kcpSettings"].(map[string]any) kcp, _ := stream["kcpSettings"].(map[string]any)
header, _ := kcp["header"].(map[string]any) header, _ := kcp["header"].(map[string]any)
obj["type"], _ = header["type"].(string) baseObj["type"], _ = header["type"].(string)
obj["path"], _ = kcp["seed"].(string) baseObj["path"], _ = kcp["seed"].(string)
case "ws": case "ws":
ws, _ := stream["wsSettings"].(map[string]any) ws, _ := stream["wsSettings"].(map[string]any)
obj["path"] = ws["path"].(string) baseObj["path"] = ws["path"].(string)
if host, ok := ws["host"].(string); ok && len(host) > 0 { if host, ok := ws["host"].(string); ok && len(host) > 0 {
obj["host"] = host baseObj["host"] = host
} else { } else {
headers, _ := ws["headers"].(map[string]any) headers, _ := ws["headers"].(map[string]any)
obj["host"] = searchHost(headers) baseObj["host"] = searchHost(headers)
} }
case "grpc": case "grpc":
grpc, _ := stream["grpcSettings"].(map[string]any) grpc, _ := stream["grpcSettings"].(map[string]any)
obj["path"] = grpc["serviceName"].(string) baseObj["path"] = grpc["serviceName"].(string)
obj["authority"] = grpc["authority"].(string) baseObj["authority"] = grpc["authority"].(string)
if grpc["multiMode"].(bool) { if grpc["multiMode"].(bool) {
obj["type"] = "multi" baseObj["type"] = "multi"
} }
case "httpupgrade": case "httpupgrade":
httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any)
obj["path"] = httpupgrade["path"].(string) baseObj["path"] = httpupgrade["path"].(string)
if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 {
obj["host"] = host baseObj["host"] = host
} else { } else {
headers, _ := httpupgrade["headers"].(map[string]any) headers, _ := httpupgrade["headers"].(map[string]any)
obj["host"] = searchHost(headers) baseObj["host"] = searchHost(headers)
} }
case "xhttp": case "xhttp":
xhttp, _ := stream["xhttpSettings"].(map[string]any) xhttp, _ := stream["xhttpSettings"].(map[string]any)
obj["path"] = xhttp["path"].(string) baseObj["path"] = xhttp["path"].(string)
if host, ok := xhttp["host"].(string); ok && len(host) > 0 { if host, ok := xhttp["host"].(string); ok && len(host) > 0 {
obj["host"] = host baseObj["host"] = host
} else { } else {
headers, _ := xhttp["headers"].(map[string]any) headers, _ := xhttp["headers"].(map[string]any)
obj["host"] = searchHost(headers) baseObj["host"] = searchHost(headers)
} }
obj["mode"] = xhttp["mode"].(string) baseObj["mode"] = xhttp["mode"].(string)
} }
security, _ := stream["security"].(string) security, _ := stream["security"].(string)
obj["tls"] = security baseObj["tls"] = security
if security == "tls" { if security == "tls" {
tlsSetting, _ := stream["tlsSettings"].(map[string]any) tlsSetting, _ := stream["tlsSettings"].(map[string]any)
alpns, _ := tlsSetting["alpn"].([]any) alpns, _ := tlsSetting["alpn"].([]any)
@ -259,19 +288,19 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
for _, a := range alpns { for _, a := range alpns {
alpn = append(alpn, a.(string)) alpn = append(alpn, a.(string))
} }
obj["alpn"] = strings.Join(alpn, ",") baseObj["alpn"] = strings.Join(alpn, ",")
} }
if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { if sniValue, ok := searchKey(tlsSetting, "serverName"); ok {
obj["sni"], _ = sniValue.(string) baseObj["sni"], _ = sniValue.(string)
} }
tlsSettings, _ := searchKey(tlsSetting, "settings") tlsSettings, _ := searchKey(tlsSetting, "settings")
if tlsSetting != nil { if tlsSetting != nil {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
obj["fp"], _ = fpValue.(string) baseObj["fp"], _ = fpValue.(string)
} }
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
obj["allowInsecure"], _ = insecure.(bool) baseObj["allowInsecure"], _ = insecure.(bool)
} }
} }
} }
@ -284,18 +313,22 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
break break
} }
} }
obj["id"] = clients[clientIndex].ID baseObj["id"] = clients[clientIndex].ID
obj["scy"] = clients[clientIndex].Security baseObj["scy"] = clients[clientIndex].Security
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
// Generate links for each node address (or external proxy)
links := ""
linkIndex := 0
// First, handle external proxies if any
if len(externalProxies) > 0 { if len(externalProxies) > 0 {
links := "" for _, externalProxy := range externalProxies {
for index, externalProxy := range externalProxies {
ep, _ := externalProxy.(map[string]any) ep, _ := externalProxy.(map[string]any)
newSecurity, _ := ep["forceTls"].(string) newSecurity, _ := ep["forceTls"].(string)
newObj := map[string]any{} newObj := map[string]any{}
for key, value := range obj { for key, value := range baseObj {
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) {
newObj[key] = value newObj[key] = value
} }
@ -307,32 +340,67 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
if newSecurity != "same" { if newSecurity != "same" {
newObj["tls"] = newSecurity newObj["tls"] = newSecurity
} }
if index > 0 { if linkIndex > 0 {
links += "\n" links += "\n"
} }
jsonStr, _ := json.MarshalIndent(newObj, "", " ") jsonStr, _ := json.MarshalIndent(newObj, "", " ")
links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
linkIndex++
} }
return links return links
} }
obj["ps"] = s.genRemark(inbound, email, "") // Generate links for each node address
for _, nodeAddr := range nodeAddresses {
obj := make(map[string]any)
for k, v := range baseObj {
obj[k] = v
}
obj["add"] = nodeAddr
obj["ps"] = s.genRemark(inbound, email, "")
jsonStr, _ := json.MarshalIndent(obj, "", " ") if linkIndex > 0 {
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) links += "\n"
}
jsonStr, _ := json.MarshalIndent(obj, "", " ")
links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
linkIndex++
}
return links
} }
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
var address string
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
address = s.address
} else {
address = inbound.Listen
}
if inbound.Protocol != model.VLESS { if inbound.Protocol != model.VLESS {
return "" return ""
} }
// Get all nodes for this inbound
var nodeAddresses []string
multiMode, _ := s.settingService.GetMultiNodeMode()
if multiMode {
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
if err == nil && len(nodes) > 0 {
// Extract addresses from all nodes
for _, node := range nodes {
nodeAddr := s.extractNodeHost(node.Address)
if nodeAddr != "" {
nodeAddresses = append(nodeAddresses, nodeAddr)
}
}
}
}
// Fallback to default logic if no nodes found
var defaultAddress string
if len(nodeAddresses) == 0 {
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
defaultAddress = s.address
} else {
defaultAddress = inbound.Listen
}
nodeAddresses = []string{defaultAddress}
}
var stream map[string]any var stream map[string]any
json.Unmarshal([]byte(inbound.StreamSettings), &stream) json.Unmarshal([]byte(inbound.StreamSettings), &stream)
clients, _ := s.inboundService.GetClients(inbound) clients, _ := s.inboundService.GetClients(inbound)
@ -483,14 +551,24 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
// Generate links for each node address (or external proxy)
// Pre-allocate capacity based on external proxies or node addresses
var initialCapacity int
if len(externalProxies) > 0 {
initialCapacity = len(externalProxies)
} else {
initialCapacity = len(nodeAddresses)
}
links := make([]string, 0, initialCapacity)
// First, handle external proxies if any
if len(externalProxies) > 0 { if len(externalProxies) > 0 {
links := make([]string, 0, len(externalProxies))
for _, externalProxy := range externalProxies { for _, externalProxy := range externalProxies {
ep, _ := externalProxy.(map[string]any) ep, _ := externalProxy.(map[string]any)
newSecurity, _ := ep["forceTls"].(string) newSecurity, _ := ep["forceTls"].(string)
dest, _ := ep["dest"].(string) dest, _ := ep["dest"].(string)
port := int(ep["port"].(float64)) epPort := int(ep["port"].(float64))
link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort)
if newSecurity != "same" { if newSecurity != "same" {
params["security"] = newSecurity params["security"] = newSecurity
@ -516,31 +594,58 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
return strings.Join(links, "\n") return strings.Join(links, "\n")
} }
link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port) // Generate links for each node address
url, _ := url.Parse(link) for _, nodeAddr := range nodeAddresses {
q := url.Query() link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port)
url, _ := url.Parse(link)
q := url.Query()
for k, v := range params { for k, v := range params {
q.Add(k, v) q.Add(k, v)
}
// Set the new query values on the URL
url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "")
links = append(links, url.String())
} }
// Set the new query values on the URL return strings.Join(links, "\n")
url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "")
return url.String()
} }
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
var address string
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
address = s.address
} else {
address = inbound.Listen
}
if inbound.Protocol != model.Trojan { if inbound.Protocol != model.Trojan {
return "" return ""
} }
// Get all nodes for this inbound
var nodeAddresses []string
multiMode, _ := s.settingService.GetMultiNodeMode()
if multiMode {
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
if err == nil && len(nodes) > 0 {
// Extract addresses from all nodes
for _, node := range nodes {
nodeAddr := s.extractNodeHost(node.Address)
if nodeAddr != "" {
nodeAddresses = append(nodeAddresses, nodeAddr)
}
}
}
}
// Fallback to default logic if no nodes found
var defaultAddress string
if len(nodeAddresses) == 0 {
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
defaultAddress = s.address
} else {
defaultAddress = inbound.Listen
}
nodeAddresses = []string{defaultAddress}
}
var stream map[string]any var stream map[string]any
json.Unmarshal([]byte(inbound.StreamSettings), &stream) json.Unmarshal([]byte(inbound.StreamSettings), &stream)
clients, _ := s.inboundService.GetClients(inbound) clients, _ := s.inboundService.GetClients(inbound)
@ -680,14 +785,18 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
// Generate links for each node address (or external proxy)
links := ""
linkIndex := 0
// First, handle external proxies if any
if len(externalProxies) > 0 { if len(externalProxies) > 0 {
links := "" for _, externalProxy := range externalProxies {
for index, externalProxy := range externalProxies {
ep, _ := externalProxy.(map[string]any) ep, _ := externalProxy.(map[string]any)
newSecurity, _ := ep["forceTls"].(string) newSecurity, _ := ep["forceTls"].(string)
dest, _ := ep["dest"].(string) dest, _ := ep["dest"].(string)
port := int(ep["port"].(float64)) epPort := int(ep["port"].(float64))
link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort)
if newSecurity != "same" { if newSecurity != "same" {
params["security"] = newSecurity params["security"] = newSecurity
@ -708,40 +817,71 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
if index > 0 { if linkIndex > 0 {
links += "\n" links += "\n"
} }
links += url.String() links += url.String()
linkIndex++
} }
return links return links
} }
link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port) // Generate links for each node address
for _, nodeAddr := range nodeAddresses {
link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port)
url, _ := url.Parse(link)
q := url.Query()
url, _ := url.Parse(link) for k, v := range params {
q := url.Query() q.Add(k, v)
}
for k, v := range params { // Set the new query values on the URL
q.Add(k, v) url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "")
if linkIndex > 0 {
links += "\n"
}
links += url.String()
linkIndex++
} }
// Set the new query values on the URL return links
url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "")
return url.String()
} }
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
var address string
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
address = s.address
} else {
address = inbound.Listen
}
if inbound.Protocol != model.Shadowsocks { if inbound.Protocol != model.Shadowsocks {
return "" return ""
} }
// Get all nodes for this inbound
var nodeAddresses []string
multiMode, _ := s.settingService.GetMultiNodeMode()
if multiMode {
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
if err == nil && len(nodes) > 0 {
// Extract addresses from all nodes
for _, node := range nodes {
nodeAddr := s.extractNodeHost(node.Address)
if nodeAddr != "" {
nodeAddresses = append(nodeAddresses, nodeAddr)
}
}
}
}
// Fallback to default logic if no nodes found
var defaultAddress string
if len(nodeAddresses) == 0 {
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
defaultAddress = s.address
} else {
defaultAddress = inbound.Listen
}
nodeAddresses = []string{defaultAddress}
}
var stream map[string]any var stream map[string]any
json.Unmarshal([]byte(inbound.StreamSettings), &stream) json.Unmarshal([]byte(inbound.StreamSettings), &stream)
clients, _ := s.inboundService.GetClients(inbound) clients, _ := s.inboundService.GetClients(inbound)
@ -852,14 +992,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
externalProxies, _ := stream["externalProxy"].([]any) externalProxies, _ := stream["externalProxy"].([]any)
// Generate links for each node address (or external proxy)
links := ""
linkIndex := 0
// First, handle external proxies if any
if len(externalProxies) > 0 { if len(externalProxies) > 0 {
links := "" for _, externalProxy := range externalProxies {
for index, externalProxy := range externalProxies {
ep, _ := externalProxy.(map[string]any) ep, _ := externalProxy.(map[string]any)
newSecurity, _ := ep["forceTls"].(string) newSecurity, _ := ep["forceTls"].(string)
dest, _ := ep["dest"].(string) dest, _ := ep["dest"].(string)
port := int(ep["port"].(float64)) epPort := int(ep["port"].(float64))
link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort)
if newSecurity != "same" { if newSecurity != "same" {
params["security"] = newSecurity params["security"] = newSecurity
@ -880,27 +1024,38 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
if index > 0 { if linkIndex > 0 {
links += "\n" links += "\n"
} }
links += url.String() links += url.String()
linkIndex++
} }
return links return links
} }
link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) // Generate links for each node address
url, _ := url.Parse(link) for _, nodeAddr := range nodeAddresses {
q := url.Query() link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port)
url, _ := url.Parse(link)
q := url.Query()
for k, v := range params { for k, v := range params {
q.Add(k, v) q.Add(k, v)
}
// Set the new query values on the URL
url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "")
if linkIndex > 0 {
links += "\n"
}
links += url.String()
linkIndex++
} }
// Set the new query values on the URL return links
url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "")
return url.String()
} }
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
@ -1215,3 +1370,19 @@ func getHostFromXFH(s string) (string, error) {
} }
return s, nil return s, nil
} }
// extractNodeHost extracts the host from a node API address.
// Example: "http://192.168.1.100:8080" -> "192.168.1.100"
func (s *SubService) extractNodeHost(nodeAddress string) string {
// Remove protocol prefix
address := strings.TrimPrefix(nodeAddress, "http://")
address = strings.TrimPrefix(address, "https://")
// Extract host (remove port if present)
host, _, err := net.SplitHostPort(address)
if err != nil {
// No port, return as is
return address
}
return host
}

File diff suppressed because one or more lines are too long

View file

@ -20,11 +20,48 @@ class DBInbound {
this.streamSettings = ""; this.streamSettings = "";
this.tag = ""; this.tag = "";
this.sniffing = ""; this.sniffing = "";
this.clientStats = "" this.clientStats = "";
this.nodeId = null; // Node ID for multi-node mode - DEPRECATED: kept only for backward compatibility, use nodeIds instead
this.nodeIds = []; // Node IDs array for multi-node mode - use this for multi-node support
if (data == null) { if (data == null) {
return; return;
} }
ObjectUtil.cloneProps(this, data); ObjectUtil.cloneProps(this, data);
// Ensure nodeIds is always an array (even if empty)
// Priority: use nodeIds if available, otherwise convert from deprecated nodeId
// First check if nodeIds exists and is an array (even if empty)
// Handle nodeIds from API response - it should be an array
if (this.nodeIds !== null && this.nodeIds !== undefined) {
if (Array.isArray(this.nodeIds)) {
// nodeIds is already an array - ensure all values are numbers
if (this.nodeIds.length > 0) {
this.nodeIds = this.nodeIds.map(id => {
// Convert string to number if needed
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
return numId;
}).filter(id => !isNaN(id) && id > 0);
} else {
// Empty array is valid
this.nodeIds = [];
}
} else {
// nodeIds exists but is not an array - try to convert
// This shouldn't happen if API returns correct format, but handle it anyway
const nodeId = typeof this.nodeIds === 'string' ? parseInt(this.nodeIds, 10) : this.nodeIds;
this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : [];
}
} else if (this.nodeId !== null && this.nodeId !== undefined) {
// Convert deprecated nodeId to nodeIds array (backward compatibility)
const nodeId = typeof this.nodeId === 'string' ? parseInt(this.nodeId, 10) : this.nodeId;
this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : [];
} else {
// No nodes assigned - ensure empty array
this.nodeIds = [];
}
// Ensure nodeIds is never null or undefined - always an array
if (!Array.isArray(this.nodeIds)) {
this.nodeIds = [];
}
} }
get totalGB() { get totalGB() {
@ -116,6 +153,13 @@ class DBInbound {
sniffing: sniffing, sniffing: sniffing,
clientStats: this.clientStats, clientStats: this.clientStats,
}; };
// Include nodeIds if available (for multi-node mode)
if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) {
config.nodeIds = this.nodeIds;
} else if (this.nodeId !== null && this.nodeId !== undefined) {
// Backward compatibility: convert single nodeId to nodeIds array
config.nodeIds = [this.nodeId];
}
return Inbound.fromJson(config); return Inbound.fromJson(config);
} }

View file

@ -1075,6 +1075,8 @@ class Inbound extends XrayCommonClass {
this.tag = tag; this.tag = tag;
this.sniffing = sniffing; this.sniffing = sniffing;
this.clientStats = clientStats; this.clientStats = clientStats;
this.nodeIds = []; // Node IDs array for multi-node mode
this.nodeId = null; // Backward compatibility
} }
getClientStats() { getClientStats() {
return this.clientStats; return this.clientStats;
@ -1638,10 +1640,107 @@ class Inbound extends XrayCommonClass {
} }
} }
// Extract node host from node address (e.g., "http://192.168.1.100:8080" -> "192.168.1.100")
extractNodeHost(nodeAddress) {
if (!nodeAddress) return '';
// Remove protocol prefix
let address = nodeAddress.replace(/^https?:\/\//, '');
// Extract host (remove port if present)
const parts = address.split(':');
return parts[0] || address;
}
// Get node addresses from nodeIds - returns array of all node addresses
getNodeAddresses() {
// Check if we have nodeIds and availableNodes
if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) {
return [];
}
// Try to get availableNodes from global app object
let availableNodes = null;
if (typeof app !== 'undefined' && app.availableNodes) {
availableNodes = app.availableNodes;
} else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) {
availableNodes = window.app.availableNodes;
}
if (!availableNodes || availableNodes.length === 0) {
return [];
}
// Get addresses for all node IDs
const addresses = [];
for (const nodeId of this.nodeIds) {
const node = availableNodes.find(n => n.id === nodeId);
if (node && node.address) {
const host = this.extractNodeHost(node.address);
if (host) {
addresses.push(host);
}
}
}
return addresses;
}
// Get node addresses with their IDs - returns array of {address, nodeId}
getNodeAddressesWithIds() {
// Check if we have nodeIds and availableNodes
if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) {
return [];
}
// Try to get availableNodes from global app object
let availableNodes = null;
if (typeof app !== 'undefined' && app.availableNodes) {
availableNodes = app.availableNodes;
} else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) {
availableNodes = window.app.availableNodes;
}
if (!availableNodes || availableNodes.length === 0) {
return [];
}
// Get addresses with node IDs for all node IDs
const result = [];
for (const nodeId of this.nodeIds) {
const node = availableNodes.find(n => n.id === nodeId);
if (node && node.address) {
const host = this.extractNodeHost(node.address);
if (host) {
result.push({ address: host, nodeId: nodeId });
}
}
}
return result;
}
// Get first node address (for backward compatibility)
getNodeAddress() {
const addresses = this.getNodeAddresses();
return addresses.length > 0 ? addresses[0] : null;
}
genAllLinks(remark = '', remarkModel = '-ieo', client) { genAllLinks(remark = '', remarkModel = '-ieo', client) {
let result = []; let result = [];
let email = client ? client.email : ''; let email = client ? client.email : '';
let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
// Get all node addresses with their IDs
const nodeAddressesWithIds = this.getNodeAddressesWithIds();
// Determine addresses to use
let addressesWithIds = [];
if (nodeAddressesWithIds.length > 0) {
addressesWithIds = nodeAddressesWithIds;
} else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
addressesWithIds = [{ address: this.listen, nodeId: null }];
} else {
addressesWithIds = [{ address: location.hostname, nodeId: null }];
}
let port = this.port; let port = this.port;
const separationChar = remarkModel.charAt(0); const separationChar = remarkModel.charAt(0);
const orderChars = remarkModel.slice(1); const orderChars = remarkModel.slice(1);
@ -1650,19 +1749,26 @@ class Inbound extends XrayCommonClass {
'e': email, 'e': email,
'o': '', 'o': '',
}; };
if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) { if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) {
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); // Generate links for each node address
result.push({ addressesWithIds.forEach((addrInfo) => {
remark: r, let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
link: this.genLink(addr, port, 'same', r, client) result.push({
remark: r,
link: this.genLink(addrInfo.address, port, 'same', r, client),
nodeId: addrInfo.nodeId
});
}); });
} else { } else {
// External proxy takes precedence
this.stream.externalProxy.forEach((ep) => { this.stream.externalProxy.forEach((ep) => {
orders['o'] = ep.remark; orders['o'] = ep.remark;
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
result.push({ result.push({
remark: r, remark: r,
link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client) link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client),
nodeId: null
}); });
}); });
} }
@ -1670,7 +1776,18 @@ class Inbound extends XrayCommonClass {
} }
genInboundLinks(remark = '', remarkModel = '-ieo') { genInboundLinks(remark = '', remarkModel = '-ieo') {
let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; // Get all node addresses
const nodeAddresses = this.getNodeAddresses();
// Determine addresses to use
let addresses = [];
if (nodeAddresses.length > 0) {
addresses = nodeAddresses;
} else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
addresses = [this.listen];
} else {
addresses = [location.hostname];
}
if (this.clients) { if (this.clients) {
let links = []; let links = [];
this.clients.forEach((client) => { this.clients.forEach((client) => {
@ -1680,11 +1797,20 @@ class Inbound extends XrayCommonClass {
}); });
return links.join('\r\n'); return links.join('\r\n');
} else { } else {
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark); if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) {
// Generate links for each node address
let links = [];
addresses.forEach((addr) => {
links.push(this.genSSLink(addr, this.port, 'same', remark));
});
return links.join('\r\n');
}
if (this.protocol == Protocols.WIREGUARD) { if (this.protocol == Protocols.WIREGUARD) {
let links = []; let links = [];
this.settings.peers.forEach((p, index) => { addresses.forEach((addr) => {
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
});
}); });
return links.join('\r\n'); return links.join('\r\n');
} }
@ -1693,7 +1819,7 @@ class Inbound extends XrayCommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
return new Inbound( const inbound = new Inbound(
json.port, json.port,
json.listen, json.listen,
json.protocol, json.protocol,
@ -1702,7 +1828,14 @@ class Inbound extends XrayCommonClass {
json.tag, json.tag,
Sniffing.fromJson(json.sniffing), Sniffing.fromJson(json.sniffing),
json.clientStats json.clientStats
) );
// Restore nodeIds if present
if (json.nodeIds && Array.isArray(json.nodeIds)) {
inbound.nodeIds = json.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id);
} else if (json.nodeId !== null && json.nodeId !== undefined) {
inbound.nodeIds = [typeof json.nodeId === 'string' ? parseInt(json.nodeId, 10) : json.nodeId];
}
return inbound;
} }
toJson() { toJson() {
@ -1710,7 +1843,7 @@ class Inbound extends XrayCommonClass {
if (this.canEnableStream() || this.stream?.sockopt) { if (this.canEnableStream() || this.stream?.sockopt) {
streamSettings = this.stream.toJson(); streamSettings = this.stream.toJson();
} }
return { const result = {
port: this.port, port: this.port,
listen: this.listen, listen: this.listen,
protocol: this.protocol, protocol: this.protocol,
@ -1720,6 +1853,11 @@ class Inbound extends XrayCommonClass {
sniffing: this.sniffing.toJson(), sniffing: this.sniffing.toJson(),
clientStats: this.clientStats clientStats: this.clientStats
}; };
// Include nodeIds if present
if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) {
result.nodeIds = this.nodeIds;
}
return result;
} }
} }

View file

@ -0,0 +1,82 @@
class Node {
constructor(data) {
this.id = 0;
this.name = "";
this.address = "";
this.apiKey = "";
this.status = "unknown";
this.lastCheck = 0;
this.createdAt = 0;
this.updatedAt = 0;
if (data == null) {
return;
}
ObjectUtil.cloneProps(this, data);
}
get isOnline() {
return this.status === "online";
}
get isOffline() {
return this.status === "offline";
}
get isError() {
return this.status === "error";
}
get isUnknown() {
return this.status === "unknown" || !this.status;
}
get statusColor() {
switch (this.status) {
case 'online': return 'green';
case 'offline': return 'red';
case 'error': return 'red';
default: return 'default';
}
}
get statusIcon() {
switch (this.status) {
case 'online': return 'check-circle';
case 'offline': return 'close-circle';
case 'error': return 'exclamation-circle';
default: return 'question-circle';
}
}
get formattedLastCheck() {
if (!this.lastCheck || this.lastCheck === 0) {
return '-';
}
const date = new Date(this.lastCheck * 1000);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
toJson() {
return {
id: this.id,
name: this.name,
address: this.address,
apiKey: this.apiKey,
status: this.status,
lastCheck: this.lastCheck,
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
}
static fromJson(json) {
return new Node(json);
}
}

View file

@ -72,10 +72,24 @@ class AllSetting {
this.ldapDefaultExpiryDays = 0; this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0; this.ldapDefaultLimitIP = 0;
// Multi-node mode settings
this.multiNodeMode = false; // Multi-node mode setting
if (data == null) { if (data == null) {
return return
} }
ObjectUtil.cloneProps(this, data); ObjectUtil.cloneProps(this, data);
// Ensure multiNodeMode is boolean (handle string "true"/"false" from backend)
if (this.multiNodeMode !== undefined && this.multiNodeMode !== null) {
if (typeof this.multiNodeMode === 'string') {
this.multiNodeMode = this.multiNodeMode === 'true' || this.multiNodeMode === '1';
} else {
this.multiNodeMode = Boolean(this.multiNodeMode);
}
} else {
this.multiNodeMode = false;
}
} }
equals(other) { equals(other) {

View file

@ -1,11 +1,14 @@
package controller package controller
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"strconv" "strconv"
"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/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"
"github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/mhsanaei/3x-ui/v2/web/websocket"
@ -103,12 +106,61 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) {
// addInbound creates a new inbound configuration. // addInbound creates a new inbound configuration.
func (a *InboundController) addInbound(c *gin.Context) { func (a *InboundController) addInbound(c *gin.Context) {
// Try to get nodeIds from JSON body first (if Content-Type is application/json)
// This must be done BEFORE ShouldBind, which reads the body
var nodeIdsFromJSON []int
var nodeIdFromJSON *int
var hasNodeIdsInJSON, hasNodeIdInJSON bool
if c.ContentType() == "application/json" {
// Read raw body to extract nodeIds
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
// Parse JSON to extract nodeIds
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
// Check for nodeIds array
if nodeIdsVal, ok := jsonData["nodeIds"]; ok {
hasNodeIdsInJSON = true
if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok {
for _, val := range nodeIdsArray {
if num, ok := val.(float64); ok {
nodeIdsFromJSON = append(nodeIdsFromJSON, int(num))
} else if num, ok := val.(int); ok {
nodeIdsFromJSON = append(nodeIdsFromJSON, num)
}
}
} else if num, ok := nodeIdsVal.(float64); ok {
// Single number instead of array
nodeIdsFromJSON = append(nodeIdsFromJSON, int(num))
} else if num, ok := nodeIdsVal.(int); ok {
nodeIdsFromJSON = append(nodeIdsFromJSON, num)
}
}
// Check for nodeId (backward compatibility)
if nodeIdVal, ok := jsonData["nodeId"]; ok {
hasNodeIdInJSON = true
if num, ok := nodeIdVal.(float64); ok {
nodeId := int(num)
nodeIdFromJSON = &nodeId
} else if num, ok := nodeIdVal.(int); ok {
nodeIdFromJSON = &num
}
}
}
// Restore body for ShouldBind
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
inbound := &model.Inbound{} inbound := &model.Inbound{}
err := c.ShouldBind(inbound) err := c.ShouldBind(inbound)
if err != nil { if err != nil {
logger.Errorf("Failed to bind inbound data: %v", err)
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
return return
} }
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
inbound.UserId = user.Id inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
@ -119,9 +171,65 @@ func (a *InboundController) addInbound(c *gin.Context) {
inbound, needRestart, err := a.inboundService.AddInbound(inbound) inbound, needRestart, err := a.inboundService.AddInbound(inbound)
if err != nil { if err != nil {
logger.Errorf("Failed to add inbound: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
// Handle node assignment in multi-node mode
nodeService := service.NodeService{}
// Get nodeIds from form (for form-encoded requests)
nodeIdsStr := c.PostFormArray("nodeIds")
logger.Debugf("Received nodeIds from form: %v", nodeIdsStr)
// Check if nodeIds array was provided (even if empty)
nodeIdStr := c.PostForm("nodeId")
// Determine which source to use: JSON takes precedence over form data
useJSON := hasNodeIdsInJSON || hasNodeIdInJSON
useForm := (len(nodeIdsStr) > 0 || nodeIdStr != "") && !useJSON
if useJSON || useForm {
var nodeIds []int
var nodeId *int
if useJSON {
// Use data from JSON
nodeIds = nodeIdsFromJSON
nodeId = nodeIdFromJSON
} else {
// Parse nodeIds array from form
for _, idStr := range nodeIdsStr {
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
nodeIds = append(nodeIds, id)
}
}
}
// Parse single nodeId from form
if nodeIdStr != "" && nodeIdStr != "null" {
if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 {
nodeId = &parsedId
}
}
}
if len(nodeIds) > 0 {
// Assign to multiple nodes
if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil {
logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
} else if nodeId != nil && *nodeId > 0 {
// Backward compatibility: single nodeId
if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil {
logger.Warningf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err)
}
}
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
@ -160,19 +268,151 @@ func (a *InboundController) updateInbound(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return return
} }
// Try to get nodeIds from JSON body first (if Content-Type is application/json)
var nodeIdsFromJSON []int
var nodeIdFromJSON *int
var hasNodeIdsInJSON, hasNodeIdInJSON bool
if c.ContentType() == "application/json" {
// Read raw body to extract nodeIds
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
// Parse JSON to extract nodeIds
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
// Check for nodeIds array
if nodeIdsVal, ok := jsonData["nodeIds"]; ok {
hasNodeIdsInJSON = true
if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok {
for _, val := range nodeIdsArray {
if num, ok := val.(float64); ok {
nodeIdsFromJSON = append(nodeIdsFromJSON, int(num))
} else if num, ok := val.(int); ok {
nodeIdsFromJSON = append(nodeIdsFromJSON, num)
}
}
} else if num, ok := nodeIdsVal.(float64); ok {
// Single number instead of array
nodeIdsFromJSON = append(nodeIdsFromJSON, int(num))
} else if num, ok := nodeIdsVal.(int); ok {
nodeIdsFromJSON = append(nodeIdsFromJSON, num)
}
}
// Check for nodeId (backward compatibility)
if nodeIdVal, ok := jsonData["nodeId"]; ok {
hasNodeIdInJSON = true
if num, ok := nodeIdVal.(float64); ok {
nodeId := int(num)
nodeIdFromJSON = &nodeId
} else if num, ok := nodeIdVal.(int); ok {
nodeIdFromJSON = &num
}
}
}
// Restore body for ShouldBind
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
// Get nodeIds from form (for form-encoded requests)
nodeIdsStr := c.PostFormArray("nodeIds")
logger.Debugf("Received nodeIds from form: %v (count: %d)", nodeIdsStr, len(nodeIdsStr))
// Check if nodeIds array was provided
nodeIdStr := c.PostForm("nodeId")
logger.Debugf("Received nodeId from form: %s", nodeIdStr)
// Check if nodeIds or nodeId was explicitly provided in the form
_, hasNodeIds := c.GetPostForm("nodeIds")
_, hasNodeId := c.GetPostForm("nodeId")
logger.Debugf("Form has nodeIds: %v, has nodeId: %v", hasNodeIds, hasNodeId)
logger.Debugf("JSON has nodeIds: %v (values: %v), has nodeId: %v (value: %v)", hasNodeIdsInJSON, nodeIdsFromJSON, hasNodeIdInJSON, nodeIdFromJSON)
inbound := &model.Inbound{ inbound := &model.Inbound{
Id: id, Id: id,
} }
// Bind inbound data (nodeIds will be ignored since we handle it separately)
err = c.ShouldBind(inbound) err = c.ShouldBind(inbound)
if err != nil { if err != nil {
logger.Errorf("Failed to bind inbound data: %v", err)
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return return
} }
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
if err != nil { if err != nil {
logger.Errorf("Failed to update inbound: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
// Handle node assignment in multi-node mode
nodeService := service.NodeService{}
// Determine which source to use: JSON takes precedence over form data
useJSON := hasNodeIdsInJSON || hasNodeIdInJSON
useForm := (hasNodeIds || hasNodeId) && !useJSON
if useJSON || useForm {
var nodeIds []int
var nodeId *int
var hasNodeIdsFlag bool
if useJSON {
// Use data from JSON
nodeIds = nodeIdsFromJSON
nodeId = nodeIdFromJSON
hasNodeIdsFlag = hasNodeIdsInJSON
} else {
// Use data from form
hasNodeIdsFlag = hasNodeIds
// Parse nodeIds array from form
for _, idStr := range nodeIdsStr {
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
nodeIds = append(nodeIds, id)
} else {
logger.Warningf("Invalid nodeId in array: %s (error: %v)", idStr, err)
}
}
}
// Parse single nodeId from form
if nodeIdStr != "" && nodeIdStr != "null" {
if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 {
nodeId = &parsedId
}
}
}
logger.Debugf("Parsed nodeIds: %v, nodeId: %v", nodeIds, nodeId)
if len(nodeIds) > 0 {
// Assign to multiple nodes
if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil {
logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
logger.Debugf("Successfully assigned inbound %d to nodes %v", inbound.Id, nodeIds)
} else if nodeId != nil && *nodeId > 0 {
// Backward compatibility: single nodeId
if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil {
logger.Errorf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
logger.Debugf("Successfully assigned inbound %d to node %d", inbound.Id, *nodeId)
} else if hasNodeIdsFlag {
// nodeIds was explicitly provided but is empty - unassign all
if err := nodeService.UnassignInboundFromNode(inbound.Id); err != nil {
logger.Warningf("Failed to unassign inbound %d from nodes: %v", inbound.Id, err)
} else {
logger.Debugf("Successfully unassigned inbound %d from all nodes", inbound.Id)
}
}
// If neither nodeIds nor nodeId was provided, don't change assignments
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
@ -367,7 +607,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) {
// onlines retrieves the list of currently online clients. // onlines retrieves the list of currently online clients.
func (a *InboundController) onlines(c *gin.Context) { func (a *InboundController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil) clients := a.inboundService.GetOnlineClients()
jsonObj(c, clients, nil)
} }
// lastOnline retrieves the last online timestamps for clients. // lastOnline retrieves the last online timestamps for clients.

297
web/controller/node.go Normal file
View file

@ -0,0 +1,297 @@
// Package controller provides HTTP handlers for node management in multi-node mode.
package controller
import (
"strconv"
"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/gin-gonic/gin"
)
// NodeController handles HTTP requests related to node management.
type NodeController struct {
nodeService service.NodeService
}
// NewNodeController creates a new NodeController and sets up its routes.
func NewNodeController(g *gin.RouterGroup) *NodeController {
a := &NodeController{
nodeService: service.NodeService{},
}
a.initRouter(g)
return a
}
// initRouter initializes the routes for node-related operations.
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getNodes)
g.GET("/get/:id", a.getNode)
g.POST("/add", a.addNode)
g.POST("/update/:id", a.updateNode)
g.POST("/del/:id", a.deleteNode)
g.POST("/check/:id", a.checkNode)
g.POST("/checkAll", a.checkAllNodes)
g.POST("/reload/:id", a.reloadNode)
g.POST("/reloadAll", a.reloadAllNodes)
g.GET("/status/:id", a.getNodeStatus)
}
// getNodes retrieves the list of all nodes.
func (a *NodeController) getNodes(c *gin.Context) {
nodes, err := a.nodeService.GetAllNodes()
if err != nil {
jsonMsg(c, "Failed to get nodes", err)
return
}
// Enrich nodes with assigned inbounds information
type NodeWithInbounds struct {
*model.Node
Inbounds []*model.Inbound `json:"inbounds,omitempty"`
}
result := make([]NodeWithInbounds, 0, len(nodes))
for _, node := range nodes {
inbounds, _ := a.nodeService.GetInboundsForNode(node.Id)
result = append(result, NodeWithInbounds{
Node: node,
Inbounds: inbounds,
})
}
jsonObj(c, result, nil)
}
// getNode retrieves a specific node by its ID.
func (a *NodeController) getNode(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
node, err := a.nodeService.GetNode(id)
if err != nil {
jsonMsg(c, "Failed to get node", err)
return
}
jsonObj(c, node, nil)
}
// addNode creates a new node.
func (a *NodeController) addNode(c *gin.Context) {
node := &model.Node{}
err := c.ShouldBind(node)
if err != nil {
jsonMsg(c, "Invalid node data", err)
return
}
// Log received data for debugging
logger.Debugf("Adding node: name=%s, address=%s, apiKey=%s", node.Name, node.Address, node.ApiKey)
// Validate API key before saving
err = a.nodeService.ValidateApiKey(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)
return
}
// Set default status
if node.Status == "" {
node.Status = "unknown"
}
err = a.nodeService.AddNode(node)
if err != nil {
jsonMsg(c, "Failed to add node", err)
return
}
// Check health immediately
go a.nodeService.CheckNodeHealth(node)
jsonMsgObj(c, "Node added successfully", node, nil)
}
// updateNode updates an existing node.
func (a *NodeController) updateNode(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
// Get existing node first to preserve fields that are not being updated
existingNode, err := a.nodeService.GetNode(id)
if err != nil {
jsonMsg(c, "Failed to get existing node", err)
return
}
// Create node with only provided fields
node := &model.Node{Id: id}
// Try to parse as JSON first (for API calls)
contentType := c.GetHeader("Content-Type")
if contentType == "application/json" {
var jsonData map[string]interface{}
if err := c.ShouldBindJSON(&jsonData); err == nil {
// Only set fields that are provided in JSON
if nameVal, ok := jsonData["name"].(string); ok && nameVal != "" {
node.Name = nameVal
}
if addressVal, ok := jsonData["address"].(string); ok && addressVal != "" {
node.Address = addressVal
}
if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" {
node.ApiKey = apiKeyVal
}
}
} else {
// Parse as form data (default for web UI)
// Only extract fields that are actually provided
if name := c.PostForm("name"); name != "" {
node.Name = name
}
if address := c.PostForm("address"); address != "" {
node.Address = address
}
if apiKey := c.PostForm("apiKey"); apiKey != "" {
node.ApiKey = apiKey
}
}
// Validate API key if it was changed
if node.ApiKey != "" && node.ApiKey != existingNode.ApiKey {
// Create a temporary node for validation
validationNode := &model.Node{
Id: id,
Address: node.Address,
ApiKey: node.ApiKey,
}
if validationNode.Address == "" {
validationNode.Address = existingNode.Address
}
err = a.nodeService.ValidateApiKey(validationNode)
if err != nil {
jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err)
return
}
}
err = a.nodeService.UpdateNode(node)
if err != nil {
jsonMsg(c, "Failed to update node", err)
return
}
jsonMsgObj(c, "Node updated successfully", node, nil)
}
// deleteNode deletes a node by its ID.
func (a *NodeController) deleteNode(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
err = a.nodeService.DeleteNode(id)
if err != nil {
jsonMsg(c, "Failed to delete node", err)
return
}
jsonMsg(c, "Node deleted successfully", nil)
}
// checkNode checks the health of a specific node.
func (a *NodeController) checkNode(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
node, err := a.nodeService.GetNode(id)
if err != nil {
jsonMsg(c, "Failed to get node", err)
return
}
err = a.nodeService.CheckNodeHealth(node)
if err != nil {
jsonMsg(c, "Node health check failed", err)
return
}
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()
jsonMsg(c, "Health check initiated for all nodes", nil)
}
// getNodeStatus retrieves the detailed status of a node.
func (a *NodeController) getNodeStatus(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
node, err := a.nodeService.GetNode(id)
if err != nil {
jsonMsg(c, "Failed to get node", err)
return
}
status, err := a.nodeService.GetNodeStatus(node)
if err != nil {
jsonMsg(c, "Failed to get node status", err)
return
}
jsonObj(c, status, nil)
}
// reloadNode reloads XRAY on a specific node.
func (a *NodeController) reloadNode(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
node, err := a.nodeService.GetNode(id)
if err != nil {
jsonMsg(c, "Failed to get node", err)
return
}
// Use force reload to handle hung nodes
err = a.nodeService.ForceReloadNode(node)
if err != nil {
jsonMsg(c, "Failed to reload node", err)
return
}
jsonMsg(c, "Node reloaded successfully", nil)
}
// reloadAllNodes reloads XRAY on all nodes.
func (a *NodeController) reloadAllNodes(c *gin.Context) {
err := a.nodeService.ReloadAllNodes()
if err != nil {
jsonMsg(c, "Failed to reload some nodes", err)
return
}
jsonMsg(c, "All nodes reloaded successfully", nil)
}

View file

@ -10,6 +10,7 @@ type XUIController struct {
settingController *SettingController settingController *SettingController
xraySettingController *XraySettingController xraySettingController *XraySettingController
nodeController *NodeController
} }
// NewXUIController creates a new XUIController and initializes its routes. // NewXUIController creates a new XUIController and initializes its routes.
@ -28,9 +29,11 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
g.GET("/nodes", a.nodes)
a.settingController = NewSettingController(g) a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g) a.xraySettingController = NewXraySettingController(g)
a.nodeController = NewNodeController(g.Group("/node"))
} }
// index renders the main panel index page. // index renders the main panel index page.
@ -52,3 +55,8 @@ func (a *XUIController) settings(c *gin.Context) {
func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil) html(c, "xray.html", "pages.xray.title", nil)
} }
// nodes renders the nodes management page (multi-node mode).
func (a *XUIController) nodes(c *gin.Context) {
html(c, "nodes.html", "pages.nodes.title", nil)
}

View file

@ -98,6 +98,9 @@ type AllSetting struct {
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"` LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
// Multi-node mode setting
MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode
// JSON subscription routing rules // JSON subscription routing rules
} }

View file

@ -43,7 +43,29 @@
Vue.component('a-sidebar', { Vue.component('a-sidebar', {
data() { data() {
return { return {
tabs: [ tabs: [],
activeTab: [
'{{ .request_uri }}'
],
visible: false,
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
multiNodeMode: false
}
},
methods: {
async loadMultiNodeMode() {
try {
const msg = await HttpUtil.post("/panel/setting/all");
if (msg && msg.success && msg.obj) {
this.multiNodeMode = msg.obj.multiNodeMode || false;
this.updateTabs();
}
} catch (e) {
console.warn("Failed to load multi-node mode:", e);
}
},
updateTabs() {
this.tabs = [
{ {
key: '{{ .base_path }}panel/', key: '{{ .base_path }}panel/',
icon: 'dashboard', icon: 'dashboard',
@ -63,21 +85,24 @@
key: '{{ .base_path }}panel/xray', key: '{{ .base_path }}panel/xray',
icon: 'tool', icon: 'tool',
title: '{{ i18n "menu.xray"}}' title: '{{ i18n "menu.xray"}}'
}, }
{ ];
key: '{{ .base_path }}logout/',
icon: 'logout', // Add Nodes menu item if multi-node mode is enabled
title: '{{ i18n "menu.logout"}}' if (this.multiNodeMode) {
}, this.tabs.splice(3, 0, {
], key: '{{ .base_path }}panel/nodes',
activeTab: [ icon: 'cluster',
'{{ .request_uri }}' title: '{{ i18n "menu.nodes"}}'
], });
visible: false, }
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
} this.tabs.push({
}, key: '{{ .base_path }}logout/',
methods: { icon: 'logout',
title: '{{ i18n "menu.logout"}}'
});
},
openLink(key) { openLink(key) {
return key.startsWith('http') ? return key.startsWith('http') ?
window.open(key) : window.open(key) :
@ -97,6 +122,14 @@
} }
} }
}, },
mounted() {
this.updateTabs();
this.loadMultiNodeMode();
// Watch for multi-node mode changes
setInterval(() => {
this.loadMultiNodeMode();
}, 5000);
},
template: `{{template "component/sidebar/content"}}`, template: `{{template "component/sidebar/content"}}`,
}); });
</script> </script>

View file

@ -31,6 +31,26 @@
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number> <a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item v-if="multiNodeMode" label="Nodes">
<template slot="extra">
<a-tooltip>
<template slot="title">
Select worker nodes where this inbound will run. You can select multiple nodes. Only available in multi-node mode.
</template>
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-select v-model="inbound.nodeIds" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme"
placeholder="Select nodes (optional)" allow-clear>
<a-select-option v-for="node in availableNodes" :key="node.id" :value="node.id">
[[ node.name ]] <a-tag :color="node.status === 'online' ? 'green' : 'red'" size="small" style="margin-left: 8px;">[[ node.status ]]</a-tag>
</a-select-option>
</a-select>
<div v-if="availableNodes.length === 0" style="margin-top: 4px; color: #ff4d4f; font-size: 12px;">
No nodes available. Please add nodes first.
</div>
</a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
<a-tooltip> <a-tooltip>

View file

@ -563,6 +563,19 @@
<a-tag color="blue">[[ dbInbound.trafficReset ]]</a-tag> <a-tag color="blue">[[ dbInbound.trafficReset ]]</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="multiNodeMode && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0">
<td>Nodes</td>
<td>
<template v-for="(nodeId, index) in dbInbound.nodeIds" :key="nodeId">
<a-tag v-if="getNodeName(nodeId)" color="blue" :style="{ margin: '0 4px 4px 0' }">
[[ getNodeName(nodeId) ]]
</a-tag>
<a-tag v-else color="gray" :style="{ margin: '0 4px 4px 0' }">
Node [[ nodeId ]]
</a-tag>
</template>
</td>
</tr>
</table> </table>
</template> </template>
<a-badge> <a-badge>
@ -706,6 +719,8 @@
el: '#app', el: '#app',
mixins: [MediaQueryMixin], mixins: [MediaQueryMixin],
data: { data: {
availableNodes: [],
multiNodeMode: false,
themeSwitcher, themeSwitcher,
persianDatepicker, persianDatepicker,
loadingStates: { loadingStates: {
@ -746,6 +761,44 @@
loading(spinning = true) { loading(spinning = true) {
this.loadingStates.spinning = spinning; this.loadingStates.spinning = spinning;
}, },
async loadMultiNodeMode() {
try {
const msg = await HttpUtil.post("/panel/setting/all");
if (msg && msg.success && msg.obj) {
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
// Store in allSetting for modal access
if (!this.allSetting) {
this.allSetting = {};
}
this.allSetting.multiNodeMode = this.multiNodeMode;
// Load available nodes if in multi-node mode
if (this.multiNodeMode) {
await this.loadAvailableNodes();
}
}
} catch (e) {
console.warn("Failed to load multi-node mode:", e);
}
},
async loadAvailableNodes() {
try {
const msg = await HttpUtil.get("/panel/node/list");
if (msg && msg.success && msg.obj) {
this.availableNodes = msg.obj.map(node => ({
id: node.id,
name: node.name,
address: node.address,
status: node.status || 'unknown'
}));
}
} catch (e) {
console.warn("Failed to load available nodes:", e);
}
},
getNodeName(nodeId) {
const node = this.availableNodes.find(n => n.id === nodeId);
return node ? node.name : null;
},
async getDBInbounds() { async getDBInbounds() {
this.refreshing = true; this.refreshing = true;
const msg = await HttpUtil.get('/panel/api/inbounds/list'); const msg = await HttpUtil.get('/panel/api/inbounds/list');
@ -804,6 +857,11 @@
this.clientCount.splice(0); this.clientCount.splice(0);
for (const inbound of dbInbounds) { for (const inbound of dbInbounds) {
const dbInbound = new DBInbound(inbound); const dbInbound = new DBInbound(inbound);
// Ensure nodeIds are properly set after creating DBInbound
// The constructor should handle this, but double-check
if (!Array.isArray(dbInbound.nodeIds)) {
dbInbound.nodeIds = [];
}
to_inbound = dbInbound.toInbound() to_inbound = dbInbound.toInbound()
this.inbounds.push(to_inbound); this.inbounds.push(to_inbound);
this.dbInbounds.push(dbInbound); this.dbInbounds.push(dbInbound);
@ -1041,6 +1099,20 @@
openEditInbound(dbInboundId) { openEditInbound(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
const inbound = dbInbound.toInbound(); const inbound = dbInbound.toInbound();
// Set nodeIds from dbInbound if available - ensure they are numbers
// This is critical: dbInbound is the source of truth for nodeIds
let nodeIdsToSet = [];
if (dbInbound.nodeIds && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0) {
nodeIdsToSet = dbInbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
} else if (dbInbound.nodeId !== null && dbInbound.nodeId !== undefined) {
// Backward compatibility: single nodeId
const nodeId = typeof dbInbound.nodeId === 'string' ? parseInt(dbInbound.nodeId, 10) : dbInbound.nodeId;
if (!isNaN(nodeId) && nodeId > 0) {
nodeIdsToSet = [nodeId];
}
}
// Ensure nodeIds are set on inbound object before passing to modal
inbound.nodeIds = nodeIdsToSet;
inModal.show({ inModal.show({
title: '{{ i18n "pages.inbounds.modifyInbound"}}', title: '{{ i18n "pages.inbounds.modifyInbound"}}',
okText: '{{ i18n "update"}}', okText: '{{ i18n "update"}}',
@ -1075,6 +1147,14 @@
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
} }
data.sniffing = inbound.sniffing.toString(); data.sniffing = inbound.sniffing.toString();
// Add nodeIds if multi-node mode is enabled
if (this.multiNodeMode && inbound.nodeIds && Array.isArray(inbound.nodeIds) && inbound.nodeIds.length > 0) {
data.nodeIds = inbound.nodeIds;
} else if (this.multiNodeMode && inbound.nodeId) {
// Backward compatibility: single nodeId
data.nodeId = inbound.nodeId;
}
await this.submit('/panel/api/inbounds/add', data, inModal); await this.submit('/panel/api/inbounds/add', data, inModal);
}, },
@ -1100,6 +1180,21 @@
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2); data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
} }
data.sniffing = inbound.sniffing.toString(); data.sniffing = inbound.sniffing.toString();
// Add nodeIds if multi-node mode is enabled
if (this.multiNodeMode) {
if (inbound.nodeIds && Array.isArray(inbound.nodeIds) && inbound.nodeIds.length > 0) {
// Ensure all values are numbers
data.nodeIds = inbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
} else if (inbound.nodeId !== null && inbound.nodeId !== undefined) {
// Backward compatibility: single nodeId
const nodeId = typeof inbound.nodeId === 'string' ? parseInt(inbound.nodeId, 10) : inbound.nodeId;
if (!isNaN(nodeId) && nodeId > 0) {
data.nodeId = nodeId;
}
}
// If no nodes selected, don't send nodeIds field at all - server will handle unassignment
}
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal); await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
}, },
@ -1252,6 +1347,10 @@
}, },
checkFallback(dbInbound) { checkFallback(dbInbound) {
newDbInbound = new DBInbound(dbInbound); newDbInbound = new DBInbound(dbInbound);
// Ensure nodeIds are preserved when creating new DBInbound
if (dbInbound.nodeIds && Array.isArray(dbInbound.nodeIds)) {
newDbInbound.nodeIds = dbInbound.nodeIds;
}
if (dbInbound.listen.startsWith("@")) { if (dbInbound.listen.startsWith("@")) {
rootInbound = this.inbounds.find((i) => rootInbound = this.inbounds.find((i) =>
i.isTcp && i.isTcp &&
@ -1312,7 +1411,10 @@
async submit(url, data, modal) { async submit(url, data, modal) {
const msg = await HttpUtil.postWithModal(url, data, modal); const msg = await HttpUtil.postWithModal(url, data, modal);
if (msg.success) { if (msg.success) {
// Force reload inbounds to get updated nodeIds from server
await this.getDBInbounds(); await this.getDBInbounds();
// Force Vue to update the view
this.$forceUpdate();
} }
}, },
getInboundClients(dbInbound) { getInboundClients(dbInbound) {
@ -1581,7 +1683,8 @@
this.searchInbounds(newVal); this.searchInbounds(newVal);
}, 500) }, 500)
}, },
mounted() { async mounted() {
await this.loadMultiNodeMode();
if (window.location.protocol !== "https:") { if (window.location.protocol !== "https:") {
this.showAlert = true; this.showAlert = true;
} }

View file

@ -78,6 +78,19 @@
</a-row> </a-row>
</a-card> </a-card>
</a-col> </a-col>
<a-col v-if="multiNodeMode" :sm="24" :lg="12">
<a-card hoverable>
<a-row :gutter="[0, isMobile ? 16 : 0]">
<a-col :span="24" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.nodesColor"
:percent="status.nodesPercent"></a-progress>
<div>
<b>{{ i18n "pages.index.nodesAvailability" }}:</b> [[ status.nodes.online ]] / [[ status.nodes.total ]]
</div>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12"> <a-col :sm="24" :lg="12">
<a-card hoverable> <a-card hoverable>
<template #title> <template #title>
@ -685,6 +698,7 @@
this.appStats = { threads: 0, mem: 0, uptime: 0 }; this.appStats = { threads: 0, mem: 0, uptime: 0 };
this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" }; this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
this.nodes = { online: 0, total: 0 };
if (data == null) { if (data == null) {
return; return;
@ -707,6 +721,11 @@
this.appUptime = data.appUptime; this.appUptime = data.appUptime;
this.appStats = data.appStats; this.appStats = data.appStats;
this.xray = data.xray; this.xray = data.xray;
if (data.nodes) {
this.nodes = { online: data.nodes.online || 0, total: data.nodes.total || 0 };
} else {
this.nodes = { online: 0, total: 0 };
}
switch (this.xray.state) { switch (this.xray.state) {
case 'running': case 'running':
this.xray.color = "green"; this.xray.color = "green";
@ -726,6 +745,24 @@
break; break;
} }
} }
get nodesPercent() {
if (this.nodes.total === 0) {
return 0;
}
return NumberFormatter.toFixed(this.nodes.online / this.nodes.total * 100, 2);
}
get nodesColor() {
const percent = this.nodesPercent;
if (percent === 100) {
return '#008771'; // Green
} else if (percent >= 50) {
return "#f37b24"; // Orange
} else {
return "#cf3c3c"; // Red
}
}
} }
const versionModal = { const versionModal = {
@ -895,12 +932,23 @@
showAlert: false, showAlert: false,
showIp: false, showIp: false,
ipLimitEnable: false, ipLimitEnable: false,
multiNodeMode: false,
}, },
methods: { methods: {
loading(spinning, tip = '{{ i18n "loading"}}') { loading(spinning, tip = '{{ i18n "loading"}}') {
this.loadingStates.spinning = spinning; this.loadingStates.spinning = spinning;
this.loadingTip = tip; this.loadingTip = tip;
}, },
async loadMultiNodeMode() {
try {
const msg = await HttpUtil.post("/panel/setting/all");
if (msg && msg.success && msg.obj) {
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
}
} catch (e) {
console.warn("Failed to load multi-node mode:", 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');
@ -1127,6 +1175,9 @@
this.ipLimitEnable = msg.obj.ipLimitEnable; this.ipLimitEnable = msg.obj.ipLimitEnable;
} }
// Load multi-node mode setting
await this.loadMultiNodeMode();
// Initial status fetch // Initial status fetch
await this.getStatus(); await this.getStatus();

View file

@ -23,6 +23,19 @@
<a-tag>[[ dbInbound.port ]]</a-tag> <a-tag>[[ dbInbound.port ]]</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="multiNodeMode && Array.isArray(dbInbound.nodeIds) && dbInbound.nodeIds.length > 0">
<td>Nodes</td>
<td>
<template v-for="(nodeId, index) in dbInbound.nodeIds" :key="nodeId">
<a-tag v-if="getNodeName(nodeId)" color="blue" :style="{ margin: '0 4px 4px 0' }">
[[ getNodeName(nodeId) ]]
</a-tag>
<a-tag v-else color="gray" :style="{ margin: '0 4px 4px 0' }">
Node [[ nodeId ]]
</a-tag>
</template>
</td>
</tr>
</table> </table>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
@ -508,8 +521,17 @@
clientIps: '', clientIps: '',
show(dbInbound, index) { show(dbInbound, index) {
this.index = index; this.index = index;
this.inbound = dbInbound.toInbound(); // Create DBInbound first to ensure nodeIds are properly processed
this.dbInbound = new DBInbound(dbInbound); this.dbInbound = new DBInbound(dbInbound);
// Ensure nodeIds are properly set - they should be an array
if (!Array.isArray(this.dbInbound.nodeIds)) {
this.dbInbound.nodeIds = [];
}
this.inbound = this.dbInbound.toInbound();
// Ensure inbound also has nodeIds from dbInbound
if (this.dbInbound.nodeIds && Array.isArray(this.dbInbound.nodeIds) && this.dbInbound.nodeIds.length > 0) {
this.inbound.nodeIds = this.dbInbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
}
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null; this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry; this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null; this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
@ -563,6 +585,12 @@
get inbound() { get inbound() {
return this.infoModal.inbound; return this.infoModal.inbound;
}, },
get multiNodeMode() {
return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false;
},
get availableNodes() {
return app && app.availableNodes || [];
},
get isActive() { get isActive() {
if (infoModal.clientStats) { if (infoModal.clientStats) {
return infoModal.clientStats.enable; return infoModal.clientStats.enable;
@ -629,6 +657,10 @@
}) })
.catch(() => {}); .catch(() => {});
}, },
getNodeName(nodeId) {
const node = this.availableNodes.find(n => n.id === nodeId);
return node ? node.name : null;
},
}, },
}); });
</script> </script>

View file

@ -22,11 +22,13 @@
show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) { show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) {
this.title = title; this.title = title;
this.okText = okText; this.okText = okText;
if (inbound) { if (inbound) {
this.inbound = Inbound.fromJson(inbound.toJson()); this.inbound = Inbound.fromJson(inbound.toJson());
} else { } else {
this.inbound = new Inbound(); this.inbound = new Inbound();
} }
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet) // Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
// This ensures Vue reactivity works properly // This ensures Vue reactivity works properly
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) { if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
@ -35,14 +37,42 @@
this.inbound.settings.testseed = [900, 500, 900, 256].slice(); this.inbound.settings.testseed = [900, 500, 900, 256].slice();
} }
} }
if (dbInbound) { if (dbInbound) {
this.dbInbound = new DBInbound(dbInbound); this.dbInbound = new DBInbound(dbInbound);
} else { } else {
this.dbInbound = new DBInbound(); this.dbInbound = new DBInbound();
} }
// Set nodeIds - ensure it's always an array for Vue reactivity
let nodeIdsToSet = [];
if (dbInbound) {
const dbInboundObj = new DBInbound(dbInbound);
if (dbInboundObj.nodeIds && Array.isArray(dbInboundObj.nodeIds) && dbInboundObj.nodeIds.length > 0) {
nodeIdsToSet = dbInboundObj.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
} else if (dbInboundObj.nodeId !== null && dbInboundObj.nodeId !== undefined) {
const nodeId = typeof dbInboundObj.nodeId === 'string' ? parseInt(dbInboundObj.nodeId, 10) : dbInboundObj.nodeId;
if (!isNaN(nodeId) && nodeId > 0) {
nodeIdsToSet = [nodeId];
}
}
} else if (inbound && inbound.nodeIds && Array.isArray(inbound.nodeIds)) {
// Use nodeIds from inbound if dbInbound is not provided
nodeIdsToSet = inbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0);
}
// Set nodeIds directly first
this.inbound.nodeIds = nodeIdsToSet;
this.confirm = confirm; this.confirm = confirm;
this.visible = true; this.visible = true;
this.isEdit = isEdit; this.isEdit = isEdit;
// Ensure Vue reactivity - inModal is in Vue's data, so we can use $set on inModal.inbound
if (inboundModalVueInstance && inboundModalVueInstance.$set) {
// Use $set to ensure Vue tracks nodeIds property on the inbound object
inboundModalVueInstance.$set(inModal.inbound, 'nodeIds', nodeIdsToSet);
}
}, },
close() { close() {
inModal.visible = false; inModal.visible = false;
@ -108,6 +138,12 @@
get datepicker() { get datepicker() {
return app.datepicker; return app.datepicker;
}, },
get multiNodeMode() {
return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false;
},
get availableNodes() {
return app && app.availableNodes || [];
},
get delayedExpireDays() { get delayedExpireDays() {
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0; return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
}, },

View file

@ -0,0 +1,232 @@
{{define "modals/nodeModal"}}
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title" :confirm-loading="nodeModal.confirmLoading"
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :class="themeSwitcher.currentTheme" :ok-text="nodeModal.okText" :width="600">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<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="http://192.168.1.100:8080"></a-input>
<div style="margin-top: 4px; color: #999; font-size: 12px;">
{{ i18n "pages.nodes.fullUrlHint" }}
</div>
</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>
<div style="margin-top: 4px; color: #999; font-size: 12px;">
{{ i18n "pages.nodes.apiKeyHint" }}
</div>
</a-form-item>
</a-form>
</a-modal>
<script>
// Make nodeModal globally available to ensure it works with any base path
const nodeModal = window.nodeModal = {
visible: false,
title: '',
confirmLoading: false,
okText: '{{ i18n "sure" }}',
isEdit: false,
currentNode: null,
confirm: null,
formData: {
name: '',
address: '',
apiKey: ''
},
ok() {
// Validate form data
if (!this.formData.name || !this.formData.name.trim()) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
return;
}
if (!this.formData.address || !this.formData.address.trim()) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
return;
}
if (!/^https?:\/\/.+/.test(this.formData.address)) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.validUrl" }}');
return;
}
if (!this.formData.apiKey || !this.formData.apiKey.trim()) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
return;
}
this.confirmLoading = true;
if (this.confirm) {
const result = this.confirm({ ...this.formData });
// If confirm returns a promise, handle it
if (result && typeof result.then === 'function') {
result.catch(() => {
this.confirmLoading = false;
});
} else {
// If not async, reset loading after a short delay
setTimeout(() => {
this.confirmLoading = false;
}, 100);
}
} else {
this.confirmLoading = false;
}
},
show({ title = '', okText = '{{ i18n "sure" }}', node = null, confirm = (data) => { }, isEdit = false }) {
console.log('[nodeModal.show] START - called with:', { title, okText, node, isEdit });
console.log('[nodeModal.show] this.visible before:', this.visible);
console.log('[nodeModal.show] nodeModalVueInstance:', nodeModalVueInstance);
// Update properties using 'this' like in inbound_modal
this.title = title;
this.okText = okText;
this.isEdit = isEdit;
this.confirm = confirm;
console.log('[nodeModal.show] Properties updated:', { title: this.title, okText: this.okText, isEdit: this.isEdit });
if (node) {
this.currentNode = node;
this.formData = {
name: node.name || '',
address: node.address || '',
apiKey: node.apiKey || ''
};
console.log('[nodeModal.show] Node data set:', this.formData);
} else {
this.currentNode = null;
this.formData = {
name: '',
address: '',
apiKey: ''
};
console.log('[nodeModal.show] Form data reset (new node)');
}
// Set visible - Vue will track this since nodeModal is in Vue instance data
console.log('[nodeModal.show] Setting this.visible = true');
this.visible = true;
console.log('[nodeModal.show] this.visible after setting:', this.visible);
// Check Vue instance
if (nodeModalVueInstance) {
console.log('[nodeModal.show] Vue instance exists');
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal:', nodeModalVueInstance.nodeModal);
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
console.log('[nodeModal.show] nodeModalVueInstance.$el:', nodeModalVueInstance.$el);
} else {
console.warn('[nodeModal.show] WARNING - Vue instance does not exist!');
}
// Check DOM element
const modalElement = document.getElementById('node-modal');
console.log('[nodeModal.show] Modal element in DOM:', modalElement);
if (modalElement) {
console.log('[nodeModal.show] Modal element classes:', modalElement.className);
console.log('[nodeModal.show] Modal element style.display:', modalElement.style.display);
const computedStyle = window.getComputedStyle(modalElement);
console.log('[nodeModal.show] Modal element computed display:', computedStyle.display);
console.log('[nodeModal.show] Modal element computed visibility:', computedStyle.visibility);
console.log('[nodeModal.show] Modal element computed opacity:', computedStyle.opacity);
console.log('[nodeModal.show] Modal element computed z-index:', computedStyle.zIndex);
// Check for Ant Design modal root
const modalRoot = document.querySelector('.ant-modal-root');
console.log('[nodeModal.show] Ant Design modal root exists:', !!modalRoot);
if (modalRoot) {
console.log('[nodeModal.show] Modal root style.display:', window.getComputedStyle(modalRoot).display);
const modalWrap = modalRoot.querySelector('.ant-modal-wrap');
console.log('[nodeModal.show] Modal wrap exists:', !!modalWrap);
if (modalWrap) {
console.log('[nodeModal.show] Modal wrap style.display:', window.getComputedStyle(modalWrap).display);
const modalInWrap = modalWrap.querySelector('#node-modal');
console.log('[nodeModal.show] Modal #node-modal in wrap:', !!modalInWrap);
}
}
} else {
console.error('[nodeModal.show] ERROR - Modal element #node-modal not found in DOM!');
}
// Use nextTick to check after Vue updates
if (nodeModalVueInstance) {
nodeModalVueInstance.$nextTick(() => {
console.log('[nodeModal.show] After $nextTick - nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
const modalElementAfter = document.getElementById('node-modal');
if (modalElementAfter) {
const modalRootAfter = document.querySelector('.ant-modal-root');
if (modalRootAfter) {
console.log('[nodeModal.show] After $nextTick - Modal root display:', window.getComputedStyle(modalRootAfter).display);
}
}
});
}
console.log('[nodeModal.show] END');
},
cancel() {
nodeModal.visible = false;
// Reset form data
nodeModal.formData = {
name: '',
address: '',
apiKey: ''
};
},
close() {
nodeModal.visible = false;
nodeModal.confirmLoading = false;
},
loading(loading = true) {
this.confirmLoading = loading;
}
};
// Store Vue instance globally to ensure methods are always accessible
let nodeModalVueInstance = null;
// Create Vue instance after main app is ready
window.initNodeModalVue = function initNodeModalVue() {
if (nodeModalVueInstance) {
return; // Already initialized
}
const modalElement = document.getElementById('node-modal');
if (!modalElement) {
setTimeout(initNodeModalVue, 50);
return;
}
try {
nodeModalVueInstance = new Vue({
delimiters: ['[[', ']]'],
el: '#node-modal',
data: {
nodeModal: nodeModal,
get themeSwitcher() {
// Try to get themeSwitcher from window or global scope
if (typeof window !== 'undefined' && window.themeSwitcher) {
return window.themeSwitcher;
}
if (typeof themeSwitcher !== 'undefined') {
return themeSwitcher;
}
// Fallback to a simple object if themeSwitcher is not available
return { currentTheme: 'light' };
}
}
});
window.nodeModalVueInstance = nodeModalVueInstance;
} catch (error) {
console.error('[nodeModal init] ERROR creating Vue instance:', error);
}
};
// Wait for DOM and main app to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(window.initNodeModalVue, 100);
});
} else {
setTimeout(window.initNodeModalVue, 100);
}
</script>
{{end}}

View file

@ -124,12 +124,25 @@
}); });
}); });
} else { } else {
this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, client).forEach(l => { const links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, client);
const hasMultipleNodes = links.length > 1 && links.some(l => l.nodeId !== null);
links.forEach(l => {
// Use node name if multiple nodes, otherwise use remark
let displayRemark = l.remark;
if (hasMultipleNodes && l.nodeId !== null) {
const node = app.availableNodes && app.availableNodes.find(n => n.id === l.nodeId);
if (node && node.name) {
displayRemark = node.name;
}
}
this.qrcodes.push({ this.qrcodes.push({
remark: l.remark, remark: displayRemark,
link: l.link, link: l.link,
useIPv4: false, useIPv4: false,
originalLink: l.link originalLink: l.link,
nodeId: l.nodeId
}); });
}); });
} }

466
web/html/nodes.html Normal file
View file

@ -0,0 +1,466 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' nodes-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content :style="{ padding: '24px 16px' }">
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>{{ i18n "pages.nodes.title" }}</h2>
<div style="margin-bottom: 20px;">
<h3>{{ i18n "pages.nodes.addNewNode" }}</h3>
<a-input id="node-name" placeholder='{{ i18n "pages.nodes.nodeName" }}' style="width: 200px; margin-right: 10px;"></a-input>
<a-input id="node-address" placeholder='{{ i18n "pages.nodes.nodeAddress" }} (http://192.168.1.100)' style="width: 250px; margin-right: 10px;"></a-input>
<a-input id="node-port" placeholder='{{ i18n "pages.nodes.nodePort" }} (8080)' type="number" style="width: 120px; margin-right: 10px;"></a-input>
<a-input-password id="node-apikey" placeholder='{{ i18n "pages.nodes.nodeApiKey" }}' style="width: 200px; margin-right: 10px;"></a-input-password>
<a-button type="primary" onclick="addNode()">{{ i18n "pages.nodes.addNode" }}</a-button>
</div>
<div style="margin-bottom: 20px;">
<a-button icon="sync" @click="loadNodes" :loading="refreshing">{{ i18n "refresh" }}</a-button>
<a-button icon="check-circle" @click="checkAllNodes" :loading="checkingAll" style="margin-left: 10px;">{{ i18n "pages.nodes.checkAll" }}</a-button>
<a-button icon="reload" @click="reloadAllNodes" :loading="reloadingAll" style="margin-left: 10px;">{{ i18n "pages.nodes.reloadAll" }}</a-button>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="node => node.id"
:data-source="nodes" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="nodes-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, node">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay" @click="a => clickAction(a, node)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="check">
<a-icon type="check-circle"></a-icon>
{{ i18n "pages.nodes.check" }}
</a-menu-item>
<a-menu-item key="reload">
<a-icon type="reload"></a-icon>
{{ i18n "pages.nodes.reload" }}
</a-menu-item>
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="delete" :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
{{ i18n "delete" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="status" slot-scope="text, node">
<a-tag :color="getStatusColor(node.status)">
[[ node.status || 'unknown' ]]
</a-tag>
</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' }">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-tag>
</template>
<a-tag v-else color="default">{{ i18n "none" }}</a-tag>
</template>
<template slot="name" slot-scope="text, node">
<template v-if="editingNodeId === node.id">
<div style="display: inline-flex; align-items: center;">
<a-input :id="`node-name-input-${node.id}`"
v-model="editingNodeName"
@keydown.enter.native="saveNodeName(node.id)"
@keydown.esc.native="cancelEditNodeName()"
:style="{ width: '120px', marginRight: '8px' }" />
<a-icon type="check-circle" theme="filled" @click="saveNodeName(node.id)"
:style="{ color: '#52c41a', cursor: 'pointer', fontSize: '18px', marginRight: '8px' }"
title="Сохранить" />
<a-icon type="close-circle" theme="filled" @click="cancelEditNodeName()"
:style="{ color: '#ff4d4f', cursor: 'pointer', fontSize: '18px' }"
title="Отменить" />
</div>
</template>
<template v-else>
<span>[[ node.name || '-' ]]</span>
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-row v-else>
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
</a-row>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
responsive: ["xs"],
}, {
title: '{{ i18n "pages.nodes.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.nodes.name" }}',
align: 'left',
width: 120,
dataIndex: "name",
scopedSlots: { customRender: 'name' },
}, {
title: '{{ i18n "pages.nodes.address" }}',
align: 'left',
width: 200,
dataIndex: "address",
}, {
title: '{{ i18n "pages.nodes.status" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'status' },
}, {
title: '{{ i18n "pages.nodes.assignedInbounds" }}',
align: 'left',
width: 300,
scopedSlots: { customRender: 'inbounds' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
responsive: ["s"],
}, {
title: '{{ i18n "pages.nodes.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.nodes.name" }}',
align: 'left',
width: 100,
dataIndex: "name",
scopedSlots: { customRender: 'name' },
}, {
title: '{{ i18n "pages.nodes.status" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'status' },
}];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
nodes: [],
refreshing: false,
checkingAll: false,
reloadingAll: false,
editingNodeId: null,
editingNodeName: '',
},
methods: {
async loadNodes() {
this.refreshing = true;
try {
const msg = await HttpUtil.get('/panel/node/list');
if (msg && msg.success && msg.obj) {
this.nodes = msg.obj.map(node => ({
id: node.id,
name: node.name || '',
address: node.address || '',
status: node.status || 'unknown',
inbounds: node.inbounds || []
}));
}
} catch (e) {
console.error("Failed to load nodes:", e);
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
} finally {
this.refreshing = false;
this.loadingStates.fetched = true;
}
},
getStatusColor(status) {
switch (status) {
case 'online':
return 'green';
case 'offline':
return 'orange';
case 'error':
return 'red';
default:
return 'default';
}
},
clickAction(action, node) {
switch (action.key) {
case 'check':
this.checkNode(node.id);
break;
case 'reload':
this.reloadNode(node.id);
break;
case 'edit':
this.startEditNodeName(node);
break;
case 'delete':
this.deleteNode(node.id);
break;
}
},
async checkNode(id) {
try {
const msg = await HttpUtil.post(`/panel/node/check/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.checkSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.checkError" }}');
}
} catch (e) {
console.error("Failed to check node:", e);
app.$message.error('{{ i18n "pages.nodes.checkError" }}');
}
},
async checkAllNodes() {
this.checkingAll = true;
try {
const msg = await HttpUtil.post('/panel/node/checkAll');
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.checkingAll" }}');
setTimeout(() => {
this.loadNodes();
}, 2000);
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.checkError" }}');
}
} catch (e) {
console.error("Failed to check all nodes:", e);
app.$message.error('{{ i18n "pages.nodes.checkError" }}');
} finally {
this.checkingAll = false;
}
},
async deleteNode(id) {
this.$confirm({
title: '{{ i18n "pages.nodes.deleteConfirm" }}',
content: '{{ i18n "pages.nodes.deleteConfirmText" }}',
okText: '{{ i18n "sure" }}',
okType: 'danger',
cancelText: '{{ i18n "close" }}',
onOk: async () => {
try {
const msg = await HttpUtil.post(`/panel/node/del/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.deleteSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.deleteError" }}');
}
} catch (e) {
console.error("Failed to delete node:", e);
app.$message.error('{{ i18n "pages.nodes.deleteError" }}');
}
}
});
},
startEditNodeName(node) {
this.editingNodeId = node.id;
this.editingNodeName = node.name || '';
// Focus input after Vue updates DOM
this.$nextTick(() => {
const inputId = `node-name-input-${node.id}`;
const input = document.getElementById(inputId);
if (input) {
input.focus();
input.select();
}
});
},
cancelEditNodeName() {
this.editingNodeId = null;
this.editingNodeName = '';
},
async saveNodeName(nodeId) {
if (this.editingNodeId !== nodeId) {
return; // Not editing this node
}
const newName = (this.editingNodeName || '').trim();
if (!newName) {
this.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
return;
}
// Check if name changed
const node = this.nodes.find(n => n.id === nodeId);
if (node && node.name === newName) {
// No change, just cancel editing
this.cancelEditNodeName();
return;
}
try {
const msg = await HttpUtil.post(`/panel/node/update/${nodeId}`, { name: newName });
if (msg && msg.success) {
this.$message.success('{{ i18n "pages.nodes.updateSuccess" }}');
this.cancelEditNodeName();
await this.loadNodes();
} else {
this.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.updateError" }}');
}
} catch (e) {
console.error("Failed to update node name:", e);
this.$message.error('{{ i18n "pages.nodes.updateError" }}');
}
},
async updateNode(id, nodeData) {
try {
const msg = await HttpUtil.post(`/panel/node/update/${id}`, nodeData);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.updateSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.updateError" }}');
}
} catch (e) {
console.error("Failed to update node:", e);
app.$message.error('{{ i18n "pages.nodes.updateError" }}');
}
},
async reloadNode(id) {
try {
const msg = await HttpUtil.post(`/panel/node/reload/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.reloadSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.reloadError" }}');
}
} catch (e) {
console.error("Failed to reload node:", e);
app.$message.error('{{ i18n "pages.nodes.reloadError" }}');
}
},
async reloadAllNodes() {
this.reloadingAll = true;
try {
const msg = await HttpUtil.post('/panel/node/reloadAll');
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.reloadAllSuccess" }}');
setTimeout(() => {
this.loadNodes();
}, 2000);
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.reloadAllError" }}');
}
} catch (e) {
console.error("Failed to reload all nodes:", e);
app.$message.error('{{ i18n "pages.nodes.reloadAllError" }}');
} finally {
this.reloadingAll = false;
}
}
},
async mounted() {
await this.loadNodes();
}
});
async function addNode() {
const name = document.getElementById('node-name').value.trim();
const address = document.getElementById('node-address').value.trim();
const port = document.getElementById('node-port').value.trim();
const apiKey = document.getElementById('node-apikey').value;
if (!name || !address || !port || !apiKey) {
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
return;
}
// Validate address format
if (!address.match(/^https?:\/\//)) {
app.$message.error('{{ i18n "pages.nodes.validUrl" }}');
return;
}
// Validate port
const portNum = parseInt(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
app.$message.error('{{ i18n "pages.nodes.validPort" }}');
return;
}
// Construct full address
const fullAddress = `${address}:${port}`;
// Check for duplicate nodes
const existingNodes = app.$data.nodes || [];
const duplicate = existingNodes.find(node => {
try {
const nodeUrl = new URL(node.address);
const newUrl = new URL(fullAddress);
// Compare protocol, hostname, and port
const nodePort = nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80');
const newPort = newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80');
return nodeUrl.protocol === newUrl.protocol &&
nodeUrl.hostname === newUrl.hostname &&
nodePort === newPort;
} catch (e) {
// If URL parsing fails, do simple string comparison
return node.address === fullAddress;
}
});
if (duplicate) {
app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}');
return;
}
try {
const msg = await HttpUtil.post('/panel/node/add', { name, address: fullAddress, apiKey });
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
document.getElementById('node-name').value = '';
document.getElementById('node-address').value = '';
document.getElementById('node-port').value = '';
document.getElementById('node-apikey').value = '';
app.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.addError" }}');
}
} catch (error) {
console.error('Error:', error);
app.$message.error('{{ i18n "pages.nodes.addError" }}');
}
}
</script>
{{template "page/body_end" .}}

View file

@ -231,6 +231,44 @@
sample = [] sample = []
this.remarkModel.forEach(r => sample.push(this.remarkModels[r])); this.remarkModel.forEach(r => sample.push(this.remarkModels[r]));
this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator); this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator);
},
onMultiNodeModeChange(enabled) {
// Use app reference to ensure correct context
const vm = app || this;
// Ensure allSetting is initialized
if (!vm || !vm.allSetting) {
console.error('allSetting is not initialized', vm);
return;
}
// Update the value immediately
vm.allSetting.multiNodeMode = enabled;
if (enabled) {
vm.$confirm({
title: '{{ i18n "pages.settings.enableMultiNodeMode" }}',
content: '{{ i18n "pages.settings.enableMultiNodeModeConfirm" }}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "sure" }}',
cancelText: '{{ i18n "cancel" }}',
onOk: () => {
// Value already set, just update save button state
vm.saveBtnDisable = vm.oldAllSetting.equals(vm.allSetting);
},
onCancel: () => {
// Revert the value if cancelled
vm.allSetting.multiNodeMode = false;
vm.saveBtnDisable = vm.oldAllSetting.equals(vm.allSetting);
}
});
} else {
// Directly update save button state if disabling
vm.saveBtnDisable = vm.oldAllSetting.equals(vm.allSetting);
}
},
goToNodes() {
window.location.href = basePath + 'panel/nodes';
} }
}, },
methods: { methods: {
@ -271,7 +309,21 @@
} }
this.oldAllSetting = new AllSetting(msg.obj); this.oldAllSetting = new AllSetting(msg.obj);
this.allSetting = new AllSetting(msg.obj); const newSetting = new AllSetting(msg.obj);
// Ensure multiNodeMode is properly converted to boolean
if (newSetting.multiNodeMode !== undefined && newSetting.multiNodeMode !== null) {
newSetting.multiNodeMode = Boolean(newSetting.multiNodeMode);
} else {
newSetting.multiNodeMode = false;
}
// Replace the object to trigger Vue reactivity
this.allSetting = newSetting;
// Force Vue to recognize the change by using $set for nested property
this.$set(this, 'allSetting', newSetting);
app.changeRemarkSample(); app.changeRemarkSample();
this.saveBtnDisable = true; this.saveBtnDisable = true;
} }
@ -292,7 +344,10 @@
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting); const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
this.loading(false); this.loading(false);
if (msg.success) { if (msg.success) {
Vue.prototype.$message.success('{{ i18n "pages.settings.toasts.modifySettings" }}');
await this.getAllSetting(); await this.getAllSetting();
} else {
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.settings.toasts.getSettings" }}');
} }
}, },
async updateUser() { async updateUser() {

View file

@ -146,7 +146,33 @@
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="6" header='LDAP'> <a-collapse-panel key="6" header='{{ i18n "pages.settings.multiNodeMode" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.multiNodeMode" }}</template>
<template #description>{{ i18n "pages.settings.multiNodeModeDesc" }}</template>
<template #control>
<a-switch v-model="allSetting.multiNodeMode" @change="(enabled) => onMultiNodeModeChange(enabled)"></a-switch>
</template>
</a-setting-list-item>
<a-alert v-if="allSetting.multiNodeMode" type="info" :style="{ marginTop: '10px' }" show-icon>
<template slot="message">
{{ i18n "pages.settings.multiNodeModeEnabled" }}
</template>
<template slot="description">
<div>{{ i18n "pages.settings.multiNodeModeInThisMode" }}</div>
<ul style="margin: 8px 0 0 20px; padding: 0;">
<li>{{ i18n "pages.settings.multiNodeModePoint1" }}</li>
<li>{{ i18n "pages.settings.multiNodeModePoint2" }}</li>
<li>{{ i18n "pages.settings.multiNodeModePoint3" }}</li>
<li>{{ i18n "pages.settings.multiNodeModePoint4" }}</li>
</ul>
<div style="margin-top: 8px;">
<a-button type="link" size="small" @click="goToNodes">{{ i18n "pages.settings.goToNodesManagement" }}</a-button>
</div>
</template>
</a-alert>
</a-collapse-panel>
<a-collapse-panel key="7" header='LDAP'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>Enable LDAP sync</template> <template #title>Enable LDAP sync</template>
<template #control> <template #control>

View file

@ -15,6 +15,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/web/service"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
@ -33,6 +34,14 @@ func NewCheckClientIpJob() *CheckClientIpJob {
} }
func (j *CheckClientIpJob) Run() { func (j *CheckClientIpJob) Run() {
// Check if multi-node mode is enabled
settingService := service.SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err == nil && multiMode {
// In multi-node mode, IP checking is handled by nodes
return
}
if j.lastClear == 0 { if j.lastClear == 0 {
j.lastClear = time.Now().Unix() j.lastClear = time.Now().Unix()
} }

View file

@ -0,0 +1,51 @@
// Package job provides scheduled background jobs for the 3x-ui panel.
package job
import (
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// CheckNodeHealthJob periodically checks the health of all nodes in multi-node mode.
type CheckNodeHealthJob struct {
nodeService service.NodeService
}
// NewCheckNodeHealthJob creates a new job for checking node health.
func NewCheckNodeHealthJob() *CheckNodeHealthJob {
return &CheckNodeHealthJob{
nodeService: service.NodeService{},
}
}
// Run executes the health check for all nodes.
func (j *CheckNodeHealthJob) 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.Errorf("Failed to get nodes for health check: %v", err)
return
}
if len(nodes) == 0 {
return // No nodes to check
}
logger.Debugf("Checking health of %d nodes", len(nodes))
for _, node := range nodes {
n := node // Capture loop variable
go func() {
if err := j.nodeService.CheckNodeHealth(n); err != nil {
logger.Debugf("Node %s (%s) health check failed: %v", n.Name, n.Address, err)
} else {
logger.Debugf("Node %s (%s) is %s", n.Name, n.Address, n.Status)
}
}()
}
}

View file

@ -20,6 +20,13 @@ func NewCheckXrayRunningJob() *CheckXrayRunningJob {
// Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks. // Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks.
func (j *CheckXrayRunningJob) Run() { func (j *CheckXrayRunningJob) Run() {
// Skip in multi-node mode - there's no local Xray process to check
settingService := service.SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err == nil && multiMode {
return // Skip if multi-node mode is enabled
}
if !j.xrayService.DidXrayCrash() { if !j.xrayService.DidXrayCrash() {
j.checkTime = 0 j.checkTime = 0
} else { } else {

View file

@ -0,0 +1,31 @@
// Package job provides background job implementations for the 3x-ui panel.
package job
import (
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// CollectNodeStatsJob collects traffic and online clients statistics from all nodes.
type CollectNodeStatsJob struct {
nodeService service.NodeService
}
// NewCollectNodeStatsJob creates a new CollectNodeStatsJob instance.
func NewCollectNodeStatsJob() *CollectNodeStatsJob {
return &CollectNodeStatsJob{
nodeService: service.NodeService{},
}
}
// Run executes the job to collect statistics from all nodes.
func (j *CollectNodeStatsJob) Run() {
logger.Debug("Starting node stats collection job")
if err := j.nodeService.CollectNodeStats(); err != nil {
logger.Errorf("Failed to collect node stats: %v", err)
return
}
logger.Debug("Node stats collection job completed successfully")
}

View file

@ -35,8 +35,26 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
if err != nil && err != gorm.ErrRecordNotFound { if err != nil && err != gorm.ErrRecordNotFound {
return nil, err return nil, err
} }
// Enrich client stats with UUID/SubId from inbound settings
// Enrich with node assignments
nodeService := NodeService{}
for _, inbound := range inbounds { for _, inbound := range inbounds {
// Load all nodes for this inbound
nodes, err := nodeService.GetNodesForInbound(inbound.Id)
if err == nil && len(nodes) > 0 {
nodeIds := make([]int, len(nodes))
for i, node := range nodes {
nodeIds[i] = node.Id
}
inbound.NodeIds = nodeIds
// Don't set nodeId - it's deprecated and causes confusion
// nodeId is only for backward compatibility when receiving data from old clients
} else {
// Ensure empty array if no nodes assigned
inbound.NodeIds = []int{}
}
// Enrich client stats with UUID/SubId from inbound settings
clients, _ := s.GetClients(inbound) clients, _ := s.GetClients(inbound)
if len(clients) == 0 || len(inbound.ClientStats) == 0 { if len(clients) == 0 || len(inbound.ClientStats) == 0 {
continue continue
@ -347,6 +365,13 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
// Delete node mappings for this inbound (cascade delete)
err = db.Where("inbound_id = ?", id).Delete(&model.InboundNodeMapping{}).Error
if err != nil {
return false, err
}
inbound, err := s.GetInbound(id) inbound, err := s.GetInbound(id)
if err != nil { if err != nil {
return false, err return false, err
@ -372,6 +397,23 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Enrich with node assignments
nodeService := NodeService{}
nodes, err := nodeService.GetNodesForInbound(inbound.Id)
if err == nil && len(nodes) > 0 {
nodeIds := make([]int, len(nodes))
for i, node := range nodes {
nodeIds[i] = node.Id
}
inbound.NodeIds = nodeIds
// Don't set nodeId - it's deprecated and causes confusion
// nodeId is only for backward compatibility when receiving data from old clients
} else {
// Ensure empty array if no nodes assigned
inbound.NodeIds = []int{}
}
return inbound, nil return inbound, nil
} }
@ -1055,7 +1097,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
} }
// Set onlineUsers // Set onlineUsers
p.SetOnlineClients(onlineClients) if p != nil {
p.SetOnlineClients(onlineClients)
}
err = tx.Save(dbClientTraffics).Error err = tx.Save(dbClientTraffics).Error
if err != nil { if err != nil {
@ -2329,6 +2373,9 @@ func (s *InboundService) MigrateDB() {
} }
func (s *InboundService) GetOnlineClients() []string { func (s *InboundService) GetOnlineClients() []string {
if p == nil {
return []string{}
}
return p.GetOnlineClients() return p.GetOnlineClients()
} }

624
web/service/node.go Normal file
View file

@ -0,0 +1,624 @@
// Package service provides Node management service for multi-node architecture.
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"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/xray"
)
// NodeService provides business logic for managing nodes in multi-node mode.
type NodeService struct{}
// GetAllNodes retrieves all nodes from the database.
func (s *NodeService) GetAllNodes() ([]*model.Node, error) {
db := database.GetDB()
var nodes []*model.Node
err := db.Find(&nodes).Error
return nodes, err
}
// GetNode retrieves a node by ID.
func (s *NodeService) GetNode(id int) (*model.Node, error) {
db := database.GetDB()
var node model.Node
err := db.First(&node, id).Error
if err != nil {
return nil, err
}
return &node, nil
}
// AddNode creates a new node.
func (s *NodeService) AddNode(node *model.Node) error {
db := database.GetDB()
return db.Create(node).Error
}
// 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 {
db := database.GetDB()
// Get existing node to preserve fields that are not being updated
existingNode, err := s.GetNode(node.Id)
if err != nil {
return fmt.Errorf("failed to get existing node: %w", err)
}
// Update only provided fields
updates := make(map[string]interface{})
if node.Name != "" {
updates["name"] = node.Name
}
if node.Address != "" {
updates["address"] = node.Address
}
if node.ApiKey != "" {
updates["api_key"] = node.ApiKey
}
// Update status 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.LastCheck > 0 && node.LastCheck != existingNode.LastCheck {
updates["last_check"] = node.LastCheck
}
// If no fields to update, return early
if len(updates) == 0 {
return nil
}
// Update only the specified fields
return db.Model(existingNode).Updates(updates).Error
}
// DeleteNode deletes a node by ID.
// This will cascade delete all InboundNodeMapping entries for this node.
func (s *NodeService) DeleteNode(id int) error {
db := database.GetDB()
// Delete all node mappings for this node (cascade delete)
err := db.Where("node_id = ?", id).Delete(&model.InboundNodeMapping{}).Error
if err != nil {
return err
}
// Delete the node itself
return db.Delete(&model.Node{}, id).Error
}
// CheckNodeHealth checks if a node is online and updates its status.
func (s *NodeService) CheckNodeHealth(node *model.Node) error {
status, err := s.CheckNodeStatus(node)
if err != nil {
node.Status = "error"
node.LastCheck = time.Now().Unix()
s.UpdateNode(node)
return err
}
node.Status = status
node.LastCheck = time.Now().Unix()
return s.UpdateNode(node)
}
// CheckNodeStatus performs a health check on a given node.
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
client := &http.Client{
Timeout: 5 * time.Second,
}
url := fmt.Sprintf("%s/health", node.Address)
resp, err := client.Get(url)
if err != nil {
return "offline", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return "online", nil
}
return "error", fmt.Errorf("node returned status code %d", resp.StatusCode)
}
// CheckAllNodesHealth checks health of all nodes.
func (s *NodeService) CheckAllNodesHealth() {
nodes, err := s.GetAllNodes()
if err != nil {
logger.Errorf("Failed to get nodes for health check: %v", err)
return
}
for _, node := range nodes {
go s.CheckNodeHealth(node)
}
}
// GetNodeForInbound returns the node assigned to an inbound, or nil if not assigned.
// Deprecated: Use GetNodesForInbound for multi-node support.
func (s *NodeService) GetNodeForInbound(inboundId int) (*model.Node, error) {
db := database.GetDB()
var mapping model.InboundNodeMapping
err := db.Where("inbound_id = ?", inboundId).First(&mapping).Error
if err != nil {
return nil, err // Not found is OK, means inbound is not assigned to any node
}
return s.GetNode(mapping.NodeId)
}
// GetNodesForInbound returns all nodes assigned to an inbound.
func (s *NodeService) GetNodesForInbound(inboundId int) ([]*model.Node, error) {
db := database.GetDB()
var mappings []model.InboundNodeMapping
err := db.Where("inbound_id = ?", inboundId).Find(&mappings).Error
if err != nil {
return nil, err
}
nodes := make([]*model.Node, 0, len(mappings))
for _, mapping := range mappings {
node, err := s.GetNode(mapping.NodeId)
if err == nil && node != nil {
nodes = append(nodes, node)
}
}
return nodes, nil
}
// GetInboundsForNode returns all inbounds assigned to a node.
func (s *NodeService) GetInboundsForNode(nodeId int) ([]*model.Inbound, error) {
db := database.GetDB()
var mappings []model.InboundNodeMapping
err := db.Where("node_id = ?", nodeId).Find(&mappings).Error
if err != nil {
return nil, err
}
inbounds := make([]*model.Inbound, 0, len(mappings))
for _, mapping := range mappings {
var inbound model.Inbound
err := db.First(&inbound, mapping.InboundId).Error
if err == nil {
inbounds = append(inbounds, &inbound)
}
}
return inbounds, nil
}
// NodeStatsResponse represents the response from node stats API.
type NodeStatsResponse struct {
Traffic []*NodeTraffic `json:"traffic"`
ClientTraffic []*NodeClientTraffic `json:"clientTraffic"`
OnlineClients []string `json:"onlineClients"`
}
// NodeTraffic represents traffic statistics from a node.
type NodeTraffic struct {
IsInbound bool `json:"isInbound"`
IsOutbound bool `json:"isOutbound"`
Tag string `json:"tag"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
// NodeClientTraffic represents client traffic statistics from a node.
type NodeClientTraffic struct {
Email string `json:"email"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
// GetNodeStats retrieves traffic and online clients statistics from a node.
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
url := fmt.Sprintf("%s/api/v1/stats", node.Address)
if reset {
url += "?reset=true"
}
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 stats: %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 stats NodeStatsResponse
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &stats, nil
}
// CollectNodeStats collects statistics from all nodes and aggregates them into the database.
// This should be called periodically (e.g., via cron job).
func (s *NodeService) CollectNodeStats() error {
// Check if multi-node mode is enabled
settingService := SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err != nil || !multiMode {
return nil // Skip if multi-node mode is not enabled
}
nodes, err := s.GetAllNodes()
if err != nil {
return fmt.Errorf("failed to get nodes: %w", err)
}
if len(nodes) == 0 {
return nil // No nodes to collect stats from
}
// Filter nodes: only collect stats from nodes that have assigned inbounds
nodesWithInbounds := make([]*model.Node, 0)
for _, node := range nodes {
inbounds, err := s.GetInboundsForNode(node.Id)
if err == nil && len(inbounds) > 0 {
// Only include nodes that have at least one assigned inbound
nodesWithInbounds = append(nodesWithInbounds, node)
}
}
if len(nodesWithInbounds) == 0 {
return nil // No nodes with assigned inbounds
}
// Import inbound service to aggregate traffic
inboundService := &InboundService{}
// Collect stats from nodes with assigned inbounds concurrently
type nodeStatsResult struct {
node *model.Node
stats *NodeStatsResponse
err error
}
results := make(chan nodeStatsResult, len(nodesWithInbounds))
for _, node := range nodesWithInbounds {
go func(n *model.Node) {
stats, err := s.GetNodeStats(n, false) // Don't reset counters on collection
results <- nodeStatsResult{node: n, stats: stats, err: err}
}(node)
}
// Aggregate all traffic
allTraffics := make([]*xray.Traffic, 0)
allClientTraffics := make([]*xray.ClientTraffic, 0)
onlineClientsMap := make(map[string]bool)
for i := 0; i < len(nodesWithInbounds); i++ {
result := <-results
if result.err != nil {
// Check if error is expected (XRAY not running, 404 for old nodes, etc.)
errMsg := result.err.Error()
if strings.Contains(errMsg, "XRAY is not running") ||
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)
} 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)
}
continue
}
if result.stats == nil {
continue
}
// Convert node traffic to xray.Traffic
for _, nt := range result.stats.Traffic {
allTraffics = append(allTraffics, &xray.Traffic{
IsInbound: nt.IsInbound,
IsOutbound: nt.IsOutbound,
Tag: nt.Tag,
Up: nt.Up,
Down: nt.Down,
})
}
// Convert node client traffic to xray.ClientTraffic
for _, nct := range result.stats.ClientTraffic {
allClientTraffics = append(allClientTraffics, &xray.ClientTraffic{
Email: nct.Email,
Up: nct.Up,
Down: nct.Down,
})
}
// Collect online clients
for _, email := range result.stats.OnlineClients {
onlineClientsMap[email] = true
}
}
// Aggregate traffic into database
if len(allTraffics) > 0 || len(allClientTraffics) > 0 {
_, needRestart := inboundService.AddTraffic(allTraffics, allClientTraffics)
if needRestart {
logger.Info("Traffic aggregation triggered client renewal/disabling, restart may be needed")
}
}
logger.Debugf("Collected stats from nodes: %d traffics, %d client traffics, %d online clients",
len(allTraffics), len(allClientTraffics), len(onlineClientsMap))
return nil
}
// AssignInboundToNode assigns an inbound to a node.
func (s *NodeService) AssignInboundToNode(inboundId, nodeId int) error {
db := database.GetDB()
mapping := &model.InboundNodeMapping{
InboundId: inboundId,
NodeId: nodeId,
}
return db.Save(mapping).Error
}
// AssignInboundToNodes assigns an inbound to multiple nodes.
func (s *NodeService) AssignInboundToNodes(inboundId int, nodeIds []int) error {
db := database.GetDB()
// First, remove all existing assignments
if err := db.Where("inbound_id = ?", inboundId).Delete(&model.InboundNodeMapping{}).Error; err != nil {
return err
}
// Then, create new assignments
for _, nodeId := range nodeIds {
if nodeId > 0 {
mapping := &model.InboundNodeMapping{
InboundId: inboundId,
NodeId: nodeId,
}
if err := db.Create(mapping).Error; err != nil {
return err
}
}
}
return nil
}
// UnassignInboundFromNode removes the assignment of an inbound from its node.
func (s *NodeService) UnassignInboundFromNode(inboundId int) error {
db := database.GetDB()
return db.Where("inbound_id = ?", inboundId).Delete(&model.InboundNodeMapping{}).Error
}
// ApplyConfigToNode sends XRAY configuration to a node.
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
client := &http.Client{
Timeout: 30 * time.Second,
}
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(xrayConfig))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ReloadNode reloads XRAY on a specific node.
func (s *NodeService) ReloadNode(node *model.Node) error {
client := &http.Client{
Timeout: 30 * time.Second,
}
url := fmt.Sprintf("%s/api/v1/reload", node.Address)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung).
func (s *NodeService) ForceReloadNode(node *model.Node) error {
client := &http.Client{
Timeout: 30 * time.Second,
}
url := fmt.Sprintf("%s/api/v1/force-reload", node.Address)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ReloadAllNodes reloads XRAY on all nodes.
func (s *NodeService) ReloadAllNodes() error {
nodes, err := s.GetAllNodes()
if err != nil {
return fmt.Errorf("failed to get nodes: %w", err)
}
type reloadResult struct {
node *model.Node
err error
}
results := make(chan reloadResult, len(nodes))
for _, node := range nodes {
go func(n *model.Node) {
err := s.ForceReloadNode(n) // Use force reload to handle hung nodes
results <- reloadResult{node: n, err: err}
}(node)
}
var errors []string
for i := 0; i < len(nodes); i++ {
result := <-results
if result.err != nil {
errors = append(errors, fmt.Sprintf("node %d (%s): %v", result.node.Id, result.node.Name, result.err))
}
}
if len(errors) > 0 {
return fmt.Errorf("failed to reload some nodes: %s", strings.Join(errors, "; "))
}
return nil
}
// ValidateApiKey validates the API key by making a test request to the node.
func (s *NodeService) ValidateApiKey(node *model.Node) error {
client := &http.Client{
Timeout: 5 * time.Second,
}
// First, check if node is reachable via health endpoint
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)
return fmt.Errorf("failed to connect to node: %v", err)
}
healthResp.Body.Close()
if healthResp.StatusCode != http.StatusOK {
return fmt.Errorf("node health check failed with status %d", healthResp.StatusCode)
}
// Try to get node status - this will validate the API key
url := fmt.Sprintf("%s/api/v1/status", node.Address)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}
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)
resp, err := client.Do(req)
if err != nil {
logger.Errorf("Failed to connect to node %s: %v", node.Address, err)
return fmt.Errorf("failed to connect to node: %v", err)
}
defer resp.Body.Close()
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))
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))
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
}
logger.Debugf("API key validated successfully for node %s", node.Name)
return nil
}
// GetNodeStatus retrieves the status of a node.
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
client := &http.Client{
Timeout: 5 * time.Second,
}
url := fmt.Sprintf("%s/api/v1/status", node.Address)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("node returned status %d", resp.StatusCode)
}
var status map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return nil, err
}
return status, nil
}

View file

@ -92,6 +92,10 @@ type Status struct {
Mem uint64 `json:"mem"` Mem uint64 `json:"mem"`
Uptime uint64 `json:"uptime"` Uptime uint64 `json:"uptime"`
} `json:"appStats"` } `json:"appStats"`
Nodes struct {
Online int `json:"online"`
Total int `json:"total"`
} `json:"nodes"`
} }
// Release represents information about a software release from GitHub. // Release represents information about a software release from GitHub.
@ -414,6 +418,32 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.AppStats.Uptime = 0 status.AppStats.Uptime = 0
} }
// Node statistics (only if multi-node mode is enabled)
settingService := SettingService{}
allSetting, err := settingService.GetAllSetting()
if err == nil && allSetting != nil && allSetting.MultiNodeMode {
nodeService := NodeService{}
nodes, err := nodeService.GetAllNodes()
if err == nil {
status.Nodes.Total = len(nodes)
onlineCount := 0
for _, node := range nodes {
if node.Status == "online" {
onlineCount++
}
}
status.Nodes.Online = onlineCount
} else {
// If error getting nodes, set to 0
status.Nodes.Total = 0
status.Nodes.Online = 0
}
} else {
// If multi-node mode is disabled, set to 0
status.Nodes.Total = 0
status.Nodes.Online = 0
}
return status return status
} }
@ -774,6 +804,14 @@ func (s *ServerService) GetXrayLogs(
countInt, _ := strconv.Atoi(count) countInt, _ := strconv.Atoi(count)
var entries []LogEntry var entries []LogEntry
// Check if multi-node mode is enabled
settingService := SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err == nil && multiMode {
// In multi-node mode, logs are on nodes, not locally
return nil
}
pathToAccessLog, err := xray.GetAccessLogPath() pathToAccessLog, err := xray.GetAccessLogPath()
if err != nil { if err != nil {
return nil return nil

View file

@ -94,6 +94,8 @@ var defaultValueMap = map[string]string{
"ldapDefaultTotalGB": "0", "ldapDefaultTotalGB": "0",
"ldapDefaultExpiryDays": "0", "ldapDefaultExpiryDays": "0",
"ldapDefaultLimitIP": "0", "ldapDefaultLimitIP": "0",
// Multi-node mode
"multiNodeMode": "false", // "true" for multi-mode, "false" for single-mode
} }
// SettingService provides business logic for application settings management. // SettingService provides business logic for application settings management.
@ -564,6 +566,13 @@ func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
} }
func (s *SettingService) GetIpLimitEnable() (bool, error) { func (s *SettingService) GetIpLimitEnable() (bool, error) {
// Check if multi-node mode is enabled
multiMode, err := s.GetMultiNodeMode()
if err == nil && multiMode {
// In multi-node mode, IP limiting is handled by nodes
return false, nil
}
accessLogPath, err := xray.GetAccessLogPath() accessLogPath, err := xray.GetAccessLogPath()
if err != nil { if err != nil {
return false, err return false, err
@ -652,6 +661,16 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
return s.getInt("ldapDefaultLimitIP") return s.getInt("ldapDefaultLimitIP")
} }
// GetMultiNodeMode returns whether multi-node mode is enabled.
func (s *SettingService) GetMultiNodeMode() (bool, error) {
return s.getBool("multiNodeMode")
}
// SetMultiNodeMode sets the multi-node mode setting.
func (s *SettingService) SetMultiNodeMode(enabled bool) error {
return s.setBool("multiNodeMode", enabled)
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil { if err := allSetting.CheckValid(); err != nil {
return err return err

View file

@ -3554,18 +3554,24 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in opening db file for backup: ", err) logger.Error("Error in opening db file for backup: ", err)
} }
file, err = os.Open(xray.GetConfigPath()) // Check if multi-node mode is enabled before trying to open config.json
if err == nil { multiMode, err := t.settingService.GetMultiNodeMode()
document := tu.Document( if err == nil && !multiMode {
tu.ID(chatId), file, err = os.Open(xray.GetConfigPath())
tu.File(file), if err == nil {
) document := tu.Document(
_, err = bot.SendDocument(context.Background(), document) tu.ID(chatId),
if err != nil { tu.File(file),
logger.Error("Error in uploading config.json: ", err) )
_, err = bot.SendDocument(context.Background(), document)
if err != nil {
logger.Error("Error in uploading config.json: ", err)
}
} else {
logger.Error("Error in opening config.json file for backup: ", err)
} }
} else { } else if multiMode {
logger.Error("Error in opening config.json file for backup: ", err) logger.Debug("Skipping config.json backup in multi-node mode")
} }
} }

View file

@ -3,9 +3,11 @@ package service
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"runtime" "runtime"
"sync" "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/xray" "github.com/mhsanaei/3x-ui/v2/xray"
@ -22,9 +24,11 @@ var (
// XrayService provides business logic for Xray process management. // XrayService provides business logic for Xray process management.
// It handles starting, stopping, restarting Xray, and managing its configuration. // It handles starting, stopping, restarting Xray, and managing its configuration.
// In multi-node mode, it sends configurations to nodes instead of running Xray locally.
type XrayService struct { type XrayService struct {
inboundService InboundService inboundService InboundService
settingService SettingService settingService SettingService
nodeService NodeService
xrayAPI xray.XrayAPI xrayAPI xray.XrayAPI
} }
@ -211,12 +215,24 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
} }
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged. // RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
// In multi-node mode, it sends configurations to nodes instead of restarting local Xray.
func (s *XrayService) RestartXray(isForce bool) error { func (s *XrayService) RestartXray(isForce bool) error {
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
logger.Debug("restart Xray, force:", isForce) logger.Debug("restart Xray, force:", isForce)
isManuallyStopped.Store(false) isManuallyStopped.Store(false)
// Check if multi-node mode is enabled
multiMode, err := s.settingService.GetMultiNodeMode()
if err != nil {
multiMode = false // Default to single mode on error
}
if multiMode {
return s.restartXrayMultiMode(isForce)
}
// Single mode: use local Xray
xrayConfig, err := s.GetXrayConfig() xrayConfig, err := s.GetXrayConfig()
if err != nil { if err != nil {
return err return err
@ -240,6 +256,167 @@ func (s *XrayService) RestartXray(isForce bool) error {
return nil return nil
} }
// restartXrayMultiMode handles Xray restart in multi-node mode by sending configs to nodes.
func (s *XrayService) restartXrayMultiMode(isForce bool) error {
// Initialize nodeService if not already initialized
if s.nodeService == (NodeService{}) {
s.nodeService = NodeService{}
}
// Get all nodes
nodes, err := s.nodeService.GetAllNodes()
if err != nil {
return fmt.Errorf("failed to get nodes: %w", err)
}
// Group inbounds by node
nodeInbounds := make(map[int][]*model.Inbound)
allInbounds, err := s.inboundService.GetAllInbounds()
if err != nil {
return fmt.Errorf("failed to get inbounds: %w", err)
}
// Get template config
templateConfig, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
return err
}
baseConfig := &xray.Config{}
if err := json.Unmarshal([]byte(templateConfig), baseConfig); err != nil {
return err
}
// Group inbounds by their assigned nodes
for _, inbound := range allInbounds {
if !inbound.Enable {
continue
}
// Get all nodes assigned to this inbound (multi-node support)
nodes, err := s.nodeService.GetNodesForInbound(inbound.Id)
if err != nil || len(nodes) == 0 {
// Inbound not assigned to any node, skip it (this is normal - not all inbounds need to be assigned)
logger.Debugf("Inbound %d is not assigned to any node, skipping", inbound.Id)
continue
}
// Add inbound to all assigned nodes
for _, node := range nodes {
nodeInbounds[node.Id] = append(nodeInbounds[node.Id], inbound)
}
}
// Send config to each node
for _, node := range nodes {
inbounds, ok := nodeInbounds[node.Id]
if !ok {
// No inbounds assigned to this node, skip
continue
}
// Build config for this node
nodeConfig := *baseConfig
// Preserve API inbound from template (if exists)
apiInbound := xray.InboundConfig{}
hasAPIInbound := false
for _, inbound := range baseConfig.InboundConfigs {
if inbound.Tag == "api" {
apiInbound = inbound
hasAPIInbound = true
break
}
}
nodeConfig.InboundConfigs = []xray.InboundConfig{}
// Add API inbound first if it exists
if hasAPIInbound {
nodeConfig.InboundConfigs = append(nodeConfig.InboundConfigs, apiInbound)
}
for _, inbound := range inbounds {
// Process clients (same logic as GetXrayConfig)
settings := map[string]any{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients, ok := settings["clients"].([]any)
if ok {
clientStats := inbound.ClientStats
for _, clientTraffic := range clientStats {
indexDecrease := 0
for index, client := range clients {
c := client.(map[string]any)
if c["email"] == clientTraffic.Email {
if !clientTraffic.Enable {
clients = RemoveIndex(clients, index-indexDecrease)
indexDecrease++
}
}
}
}
var final_clients []any
for _, client := range clients {
c := client.(map[string]any)
if c["enable"] != nil {
if enable, ok := c["enable"].(bool); ok && !enable {
continue
}
}
for key := range c {
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
delete(c, key)
}
if c["flow"] == "xtls-rprx-vision-udp443" {
c["flow"] = "xtls-rprx-vision"
}
}
final_clients = append(final_clients, any(c))
}
settings["clients"] = final_clients
modifiedSettings, _ := json.MarshalIndent(settings, "", " ")
inbound.Settings = string(modifiedSettings)
}
if len(inbound.StreamSettings) > 0 {
var stream map[string]any
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
tlsSettings, ok1 := stream["tlsSettings"].(map[string]any)
realitySettings, ok2 := stream["realitySettings"].(map[string]any)
if ok1 || ok2 {
if ok1 {
delete(tlsSettings, "settings")
} else if ok2 {
delete(realitySettings, "settings")
}
}
delete(stream, "externalProxy")
newStream, _ := json.MarshalIndent(stream, "", " ")
inbound.StreamSettings = string(newStream)
}
inboundConfig := inbound.GenXrayInboundConfig()
nodeConfig.InboundConfigs = append(nodeConfig.InboundConfigs, *inboundConfig)
}
// 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)
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)
// Continue with other nodes even if one fails
} else {
logger.Infof("Successfully applied config to node %d (%s)", node.Id, node.Name)
}
}
return nil
}
// StopXray stops the running Xray process. // StopXray stops the running Xray process.
func (s *XrayService) StopXray() error { func (s *XrayService) StopXray() error {
lock.Lock() lock.Lock()

View file

@ -28,6 +28,7 @@
"edit" = "Edit" "edit" = "Edit"
"delete" = "Delete" "delete" = "Delete"
"reset" = "Reset" "reset" = "Reset"
"refresh" = "Refresh"
"noData" = "No data." "noData" = "No data."
"copySuccess" = "Copied Successful" "copySuccess" = "Copied Successful"
"sure" = "Sure" "sure" = "Sure"
@ -94,6 +95,7 @@
"inbounds" = "Inbounds" "inbounds" = "Inbounds"
"settings" = "Panel Settings" "settings" = "Panel Settings"
"xray" = "Xray Configs" "xray" = "Xray Configs"
"nodes" = "Nodes"
"logout" = "Log Out" "logout" = "Log Out"
"link" = "Manage" "link" = "Manage"
@ -117,6 +119,7 @@
"swap" = "Swap" "swap" = "Swap"
"storage" = "Storage" "storage" = "Storage"
"memory" = "RAM" "memory" = "RAM"
"nodesAvailability" = "Nodes Availability"
"threads" = "Threads" "threads" = "Threads"
"xrayStatus" = "Xray" "xrayStatus" = "Xray"
"stopXray" = "Stop" "stopXray" = "Stop"
@ -407,6 +410,17 @@
"muxDesc" = "Transmit multiple independent data streams within an established data stream." "muxDesc" = "Transmit multiple independent data streams within an established data stream."
"muxSett" = "Mux Settings" "muxSett" = "Mux Settings"
"direct" = "Direct Connection" "direct" = "Direct Connection"
"multiNodeMode" = "Multi-Node Mode"
"multiNodeModeDesc" = "Enable distributed architecture with separate worker nodes. When enabled, XRAY Core runs on nodes instead of locally."
"multiNodeModeEnabled" = "Multi-Node Mode Enabled"
"multiNodeModeInThisMode" = "In this mode:"
"multiNodeModePoint1" = "XRAY Core will not run locally"
"multiNodeModePoint2" = "Configurations will be sent to worker nodes"
"multiNodeModePoint3" = "You need to assign inbounds to nodes"
"multiNodeModePoint4" = "Subscriptions will use node endpoints"
"goToNodesManagement" = "Go to Nodes Management"
"enableMultiNodeMode" = "Enable Multi-Node Mode"
"enableMultiNodeModeConfirm" = "Enabling multi-node mode will stop local XRAY Core. Make sure you have configured worker nodes before enabling this mode. Continue?"
"directDesc" = "Directly establishes connections with domains or IP ranges of a specific country." "directDesc" = "Directly establishes connections with domains or IP ranges of a specific country."
"notifications" = "Notifications" "notifications" = "Notifications"
"certs" = "Certificaties" "certs" = "Certificaties"
@ -582,6 +596,72 @@
"twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted" "twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted"
"twoFactorModalError" = "Wrong code" "twoFactorModalError" = "Wrong code"
[pages.nodes]
"title" = "Nodes Management"
"addNewNode" = "Add New Node"
"addNode" = "Add Node"
"editNode" = "Edit Node"
"deleteNode" = "Delete Node"
"checkNode" = "Check Node"
"checkAllNodes" = "Check All Nodes"
"nodeName" = "Node Name"
"nodeAddress" = "Node Address"
"nodePort" = "Port"
"nodeApiKey" = "API Key"
"nodeStatus" = "Status"
"lastCheck" = "Last Check"
"actions" = "Actions"
"operate" = "Actions"
"name" = "Name"
"address" = "Address"
"status" = "Status"
"assignedInbounds" = "Assigned Inbounds"
"checkAll" = "Check All"
"check" = "Check"
"online" = "Online"
"offline" = "Offline"
"error" = "Error"
"unknown" = "Unknown"
"enterNodeName" = "Please enter node name"
"enterNodeAddress" = "Please enter node address"
"validUrl" = "Must be a valid URL (http:// or https://)"
"validPort" = "Port must be a number between 1 and 65535"
"duplicateNode" = "A node with this address and port already exists"
"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100:8080)"
"enterApiKey" = "Please enter API key"
"apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)"
"leaveEmptyToKeep" = "leave empty to keep current"
"loadError" = "Failed to load nodes"
"checkSuccess" = "Node check completed"
"checkError" = "Failed to check node"
"checkingAll" = "Checking all nodes..."
"deleteConfirm" = "Confirm Deletion"
"deleteConfirmText" = "Are you sure you want to delete this node?"
"deleteSuccess" = "Node deleted successfully"
"deleteError" = "Failed to delete node"
"updateSuccess" = "Node updated successfully"
"updateError" = "Failed to update node"
"addSuccess" = "Node added successfully"
"addError" = "Failed to add node"
"reload" = "Reload"
"reloadAll" = "Reload All Nodes"
"reloadSuccess" = "Node reloaded successfully"
"reloadError" = "Failed to reload node"
"reloadAllSuccess" = "All nodes reloaded successfully"
"reloadAllError" = "Failed to reload some nodes"
[pages.nodes.toasts]
"createSuccess" = "Node created successfully"
"createError" = "Failed to create node"
"checkStatusSuccess" = "Node health check completed"
"checkStatusError" = "Failed to check node status"
"obtainError" = "Failed to get nodes"
"invalidId" = "Invalid node ID"
"assignSuccess" = "Inbound assigned to node successfully"
"assignError" = "Failed to assign inbound to node"
"mappingError" = "Failed to get node mapping"
"invalidInboundId" = "Invalid inbound ID"
[pages.settings.toasts] [pages.settings.toasts]
"modifySettings" = "The parameters have been changed." "modifySettings" = "The parameters have been changed."
"getSettings" = "An error occurred while retrieving parameters." "getSettings" = "An error occurred while retrieving parameters."

View file

@ -28,6 +28,7 @@
"edit" = "Изменить" "edit" = "Изменить"
"delete" = "Удалить" "delete" = "Удалить"
"reset" = "Сбросить" "reset" = "Сбросить"
"refresh" = "Обновить"
"noData" = "Нет данных." "noData" = "Нет данных."
"copySuccess" = "Скопировано" "copySuccess" = "Скопировано"
"sure" = "Да" "sure" = "Да"
@ -94,6 +95,7 @@
"inbounds" = "Подключения" "inbounds" = "Подключения"
"settings" = "Настройки" "settings" = "Настройки"
"xray" = "Настройки Xray" "xray" = "Настройки Xray"
"nodes" = "Ноды"
"logout" = "Выход" "logout" = "Выход"
"link" = "Управление" "link" = "Управление"
@ -117,6 +119,7 @@
"swap" = "Файл подкачки" "swap" = "Файл подкачки"
"storage" = "Диск" "storage" = "Диск"
"memory" = "ОЗУ" "memory" = "ОЗУ"
"nodesAvailability" = "Доступность нод"
"threads" = "Потоки" "threads" = "Потоки"
"xrayStatus" = "Xray" "xrayStatus" = "Xray"
"stopXray" = "Остановить" "stopXray" = "Остановить"
@ -408,6 +411,17 @@
"muxSett" = "Настройки Mux" "muxSett" = "Настройки Mux"
"direct" = "Прямое подключение" "direct" = "Прямое подключение"
"directDesc" = "Устанавливает прямые соединения с доменами или IP-адресами определённой страны." "directDesc" = "Устанавливает прямые соединения с доменами или IP-адресами определённой страны."
"multiNodeMode" = "Режим Multi-Node"
"multiNodeModeDesc" = "Включить распределенную архитектуру с отдельными рабочими нодами. При включении XRAY Core будет работать на нодах, а не локально."
"multiNodeModeEnabled" = "Режим Multi-Node включен"
"multiNodeModeInThisMode" = "В этом режиме:"
"multiNodeModePoint1" = "XRAY Core не будет работать локально"
"multiNodeModePoint2" = "Конфигурации будут отправляться на рабочие ноды"
"multiNodeModePoint3" = "Необходимо назначить инбаунды на ноды"
"multiNodeModePoint4" = "Подписки будут использовать адреса нод"
"goToNodesManagement" = "Перейти к управлению нодами"
"enableMultiNodeMode" = "Включить режим Multi-Node"
"enableMultiNodeModeConfirm" = "Включение режима Multi-Node остановит локальный XRAY Core. Убедитесь, что вы настроили рабочие ноды перед включением этого режима. Продолжить?"
"notifications" = "Уведомления" "notifications" = "Уведомления"
"certs" = "Сертификаты" "certs" = "Сертификаты"
"externalTraffic" = "Внешний трафик" "externalTraffic" = "Внешний трафик"
@ -582,6 +596,72 @@
"twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена" "twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена"
"twoFactorModalError" = "Неверный код" "twoFactorModalError" = "Неверный код"
[pages.nodes]
"title" = "Управление нодами"
"addNewNode" = "Добавить новую ноду"
"addNode" = "Добавить ноду"
"editNode" = "Редактировать ноду"
"deleteNode" = "Удалить ноду"
"checkNode" = "Проверить ноду"
"checkAllNodes" = "Проверить все ноды"
"nodeName" = "Имя ноды"
"nodeAddress" = "Адрес ноды"
"nodePort" = "Порт"
"nodeApiKey" = "API ключ"
"nodeStatus" = "Статус"
"lastCheck" = "Последняя проверка"
"actions" = "Действия"
"operate" = "Действия"
"name" = "Имя"
"address" = "Адрес"
"status" = "Статус"
"assignedInbounds" = "Назначенные подключения"
"checkAll" = "Проверить все"
"check" = "Проверить"
"online" = "Онлайн"
"offline" = "Офлайн"
"error" = "Ошибка"
"unknown" = "Неизвестно"
"enterNodeName" = "Пожалуйста, введите имя ноды"
"enterNodeAddress" = "Пожалуйста, введите адрес ноды"
"validUrl" = "Должен быть действительным URL (http:// или https://)"
"validPort" = "Порт должен быть числом от 1 до 65535"
"duplicateNode" = "Нода с таким адресом и портом уже существует"
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)"
"enterApiKey" = "Пожалуйста, введите API ключ"
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
"leaveEmptyToKeep" = "оставьте пустым чтобы не менять"
"loadError" = "Не удалось загрузить список нод"
"checkSuccess" = "Проверка ноды завершена"
"checkError" = "Не удалось проверить ноду"
"checkingAll" = "Проверка всех нод..."
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить эту ноду?"
"deleteSuccess" = "Нода успешно удалена"
"deleteError" = "Не удалось удалить ноду"
"updateSuccess" = "Нода успешно обновлена"
"updateError" = "Не удалось обновить ноду"
"addSuccess" = "Нода успешно добавлена"
"addError" = "Не удалось добавить ноду"
"reload" = "Перезагрузить"
"reloadAll" = "Перезагрузить все ноды"
"reloadSuccess" = "Нода успешно перезагружена"
"reloadError" = "Не удалось перезагрузить ноду"
"reloadAllSuccess" = "Все ноды успешно перезагружены"
"reloadAllError" = "Не удалось перезагрузить некоторые ноды"
[pages.nodes.toasts]
"createSuccess" = "Нода успешно создана"
"createError" = "Не удалось создать ноду"
"checkStatusSuccess" = "Проверка здоровья ноды завершена"
"checkStatusError" = "Не удалось проверить статус ноды"
"obtainError" = "Не удалось получить список нод"
"invalidId" = "Неверный ID ноды"
"assignSuccess" = "Подключение успешно назначено на ноду"
"assignError" = "Не удалось назначить подключение на ноду"
"mappingError" = "Не удалось получить привязку ноды"
"invalidInboundId" = "Неверный ID подключения"
[pages.settings.toasts] [pages.settings.toasts]
"modifySettings" = "Настройки изменены" "modifySettings" = "Настройки изменены"
"getSettings" = "Произошла ошибка при получении параметров." "getSettings" = "Произошла ошибка при получении параметров."

View file

@ -343,6 +343,11 @@ func (s *Server) startTask() {
s.cron.AddJob(runtime, j) s.cron.AddJob(runtime, j)
} }
// Node health check job (every 10 seconds)
s.cron.AddJob("@every 10s", job.NewCheckNodeHealthJob())
// Collect node statistics (traffic and online clients) every 30 seconds
s.cron.AddJob("@every 30s", job.NewCollectNodeStatsJob())
// Make a traffic condition every day, 8:30 // Make a traffic condition every day, 8:30
var entry cron.EntryID var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled() isTgbotenabled, err := s.settingService.GetTgbotEnabled()

View file

@ -69,9 +69,15 @@ func GetAccessPersistentPrevLogPath() string {
} }
// GetAccessLogPath reads the Xray config and returns the access log file path. // GetAccessLogPath reads the Xray config and returns the access log file path.
// Returns an error if the config file doesn't exist (e.g., in multi-node mode).
func GetAccessLogPath() (string, error) { func GetAccessLogPath() (string, error) {
config, err := os.ReadFile(GetConfigPath()) configPath := GetConfigPath()
config, err := os.ReadFile(configPath)
if err != nil { if err != nil {
// Don't log warning if file doesn't exist - this is normal in multi-node mode
if os.IsNotExist(err) {
return "", err
}
logger.Warningf("Failed to read configuration file: %s", err) logger.Warningf("Failed to read configuration file: %s", err)
return "", err return "", err
} }