This commit is contained in:
konstpic 2026-01-18 15:48:03 +01:00 committed by GitHub
commit 5ab168f909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 14357 additions and 1041 deletions

172
README_node_mode.md Normal file
View file

@ -0,0 +1,172 @@
# Installing 3x-ui with Multi-Node Support (Beta)
This guide describes the complete process of installing the panel and nodes from scratch.
------------------------------------------------------------------------
## Requirements
Before starting, make sure you have installed:
- Docker
- Docker Compose (v2)
Check:
```bash
docker --version
docker compose version
```
------------------------------------------------------------------------
## Step 1. Clone the Repository
```bash
git clone https://github.com/konstpic/3x-ui-dev-beta.git
cd 3x-ui-dev-beta
```
------------------------------------------------------------------------
## Step 2. Switch to the Multi-Node Support Branch
```bash
git checkout 3x-new
```
------------------------------------------------------------------------
## Step 3. Launch the Panel (Core)
In the repository root, build and start the panel:
```bash
docker compose build
docker compose up -d
```
------------------------------------------------------------------------
### (Optional) Configure Panel Ports
By default, `network_mode: host` may be used.
If you want to use standard port mapping:
1. Open `docker-compose.yml` in the project root
2. Remove `network_mode: host`
3. Add port mapping:
```yaml
ports:
- "2053:2053" # Web UI
- "2096:2096" # Subscriptions
```
After making changes, restart the containers:
```bash
docker compose down
docker compose up -d
```
------------------------------------------------------------------------
## Step 4. Launch the Node
Navigate to the `node` folder:
```bash
cd node
docker compose build
docker compose up -d
```
------------------------------------------------------------------------
## Important ❗ About Node Network and Ports (Xray)
Nodes use the **Xray** core, and it's the nodes that accept user connections to **Inbounds**.
### Option 1 (recommended): `network_mode: host`
Use `network_mode: host` if:
- you don't want to manually manage ports
- you plan to use different inbound ports
- you want behavior as close as possible to bare-metal
In this case, **no additional port mapping is required**.
------------------------------------------------------------------------
### Option 2: Using `ports` (without host network)
If `network_mode: host` is **not used**, you need to:
1. Define in advance the ports on which users will connect to inbounds
2. Map these ports in the node's `docker-compose.yml`
Example:
```yaml
ports:
- "8080:8080" # Node API
- "443:443" # Inbound (example)
- "8443:8443" # Inbound (example)
```
⚠️ In this mode, **each inbound port must be explicitly mapped**.
------------------------------------------------------------------------
### Node API Port
Regardless of the chosen mode:
- The node API runs on port **8080**
- This port must be accessible to the panel
------------------------------------------------------------------------
## Step 5. Enable Multi-Node Mode
1. Open the panel web interface
2. Go to **Panel Settings**
3. Enable **Multi-Node**
4. Save settings
After this, the **Nodes** section will appear.
------------------------------------------------------------------------
## Step 6. Register Nodes
1. Go to the **Nodes** section
2. Add a new node, specifying:
- node server address
- node API port: **8080**
3. Save
If everything is configured correctly, the node will appear with **Online** status.
------------------------------------------------------------------------
## Step 7. Using Nodes in Inbounds
After registration:
- nodes can be selected when creating and editing **Inbounds**
- user connections will be accepted **by nodes, not by the panel**
------------------------------------------------------------------------
## Possible Issues
If a node has `Offline` status or users cannot connect:
- make sure the containers are running
- check accessibility of port **8080**
- check that inbound ports are:
- mapped (if host network is not used)
- not blocked by firewall
- check Docker network settings
------------------------------------------------------------------------
⚠️ The project is in **beta stage**. Bugs and changes are possible.

View file

@ -38,6 +38,13 @@ func initModels() error {
&model.InboundClientIps{}, &model.InboundClientIps{},
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.Node{},
&model.InboundNodeMapping{},
&model.ClientEntity{},
&model.ClientInboundMapping{},
&model.Host{},
&model.HostInboundMapping{},
&model.ClientHWID{}, // HWID tracking for clients
} }
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.
@ -105,6 +107,8 @@ type Setting struct {
} }
// Client represents a client configuration for Xray inbounds with traffic limits and settings. // Client represents a client configuration for Xray inbounds with traffic limits and settings.
// This is a legacy struct used for JSON parsing from inbound Settings.
// For database operations, use ClientEntity instead.
type Client struct { type Client struct {
ID string `json:"id"` // Unique client identifier ID string `json:"id"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
@ -122,3 +126,129 @@ 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
} }
// ClientEntity represents a client as a separate database entity.
// Clients can be assigned to multiple inbounds.
type ClientEntity struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
UserId int `json:"userId" gorm:"index"` // Associated user ID
Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_user_email"` // Client email identifier (unique per user)
UUID string `json:"uuid" form:"uuid"` // UUID/ID for VMESS/VLESS
Security string `json:"security" form:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks)
Flow string `json:"flow" form:"flow"` // Flow control (XTLS)
LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client
TotalGB float64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB (supports decimal values like 0.01 for MB)
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
Status string `json:"status" form:"status" gorm:"default:active"` // Client status: active, expired_traffic, expired_time
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
// Relations (not stored in DB, loaded via joins)
InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to
// Traffic statistics (stored directly in ClientEntity table)
Up int64 `json:"up,omitempty" form:"-" gorm:"default:0"` // Upload traffic in bytes
Down int64 `json:"down,omitempty" form:"-" gorm:"default:0"` // Download traffic in bytes
AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"default:0"` // All-time traffic usage
LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"default:0"` // Last online timestamp
// HWID (Hardware ID) restrictions
HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client
MaxHWID int `json:"maxHwid" form:"maxHwid" gorm:"column:max_hwid;default:1"` // Maximum number of allowed HWID devices (0 = unlimited)
HWIDs []*ClientHWID `json:"hwids,omitempty" form:"-" gorm:"-"` // Registered HWIDs for this client (loaded from client_hwids table, not stored in ClientEntity table)
}
// 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" or "https://...")
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
ResponseTime int64 `json:"responseTime" gorm:"default:0"` // Response time in milliseconds (0 = not measured or error)
UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls
CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA)
KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA)
InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended)
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
}
// ClientInboundMapping maps clients to inbounds (many-to-many relationship).
type ClientInboundMapping struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
ClientId int `json:"clientId" form:"clientId" gorm:"uniqueIndex:idx_client_inbound"` // Client ID
InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_client_inbound"` // Inbound ID
}
// Host represents a proxy/balancer host configuration for multi-node mode.
// Hosts can override the node address when generating subscription links.
type Host struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
UserId int `json:"userId" gorm:"index"` // Associated user ID
Name string `json:"name" form:"name"` // Host name/identifier
Address string `json:"address" form:"address"` // Host address (IP or domain)
Port int `json:"port" form:"port"` // Host port (0 means use inbound port)
Protocol string `json:"protocol" form:"protocol"` // Protocol override (optional)
Remark string `json:"remark" form:"remark"` // Host remark/description
Enable bool `json:"enable" form:"enable"` // Whether the host is enabled
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
// Relations (not stored in DB, loaded via joins)
InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this host applies to
}
// HostInboundMapping maps hosts to inbounds (many-to-many relationship).
type HostInboundMapping struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
HostId int `json:"hostId" form:"hostId" gorm:"uniqueIndex:idx_host_inbound"` // Host ID
InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_host_inbound"` // Inbound ID
}
// ClientHWID represents a hardware ID (HWID) associated with a client.
// HWID is provided explicitly by client applications via HTTP headers (x-hwid).
// Server MUST NOT generate or derive HWID from IP, User-Agent, or access logs.
type ClientHWID struct {
// TableName specifies the table name for GORM
// GORM by default would use "client_hwids" but the actual table is "client_hw_ids"
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
ClientId int `json:"clientId" form:"clientId" gorm:"column:client_id;index:idx_client_hwid"` // Client ID
HWID string `json:"hwid" form:"hwid" gorm:"column:hwid;index:idx_client_hwid"` // Hardware ID (unique per client, provided by client via x-hwid header)
DeviceName string `json:"deviceName" form:"deviceName" gorm:"column:device_name"` // Optional device name/description (deprecated, use DeviceModel instead)
DeviceOS string `json:"deviceOs" form:"deviceOs" gorm:"column:device_os"` // Device operating system (from x-device-os header)
DeviceModel string `json:"deviceModel" form:"deviceModel" gorm:"column:device_model"` // Device model (from x-device-model header)
OSVersion string `json:"osVersion" form:"osVersion" gorm:"column:os_version"` // OS version (from x-ver-os header)
FirstSeenAt int64 `json:"firstSeenAt" gorm:"column:first_seen_at;autoCreateTime"` // First time this HWID was seen (timestamp)
LastSeenAt int64 `json:"lastSeenAt" gorm:"column:last_seen_at;autoUpdateTime"` // Last time this HWID was used (timestamp)
FirstSeenIP string `json:"firstSeenIp" form:"firstSeenIp" gorm:"column:first_seen_ip"` // IP address when first seen
IsActive bool `json:"isActive" form:"isActive" gorm:"column:is_active;default:true"` // Whether this HWID is currently active
IPAddress string `json:"ipAddress" form:"ipAddress" gorm:"column:ip_address"` // Last known IP address for this HWID
UserAgent string `json:"userAgent" form:"userAgent" gorm:"column:user_agent"` // User agent or client identifier (if available)
BlockedAt *int64 `json:"blockedAt,omitempty" form:"blockedAt" gorm:"column:blocked_at"` // Timestamp when HWID was blocked (null if not blocked)
BlockReason string `json:"blockReason,omitempty" form:"blockReason" gorm:"column:block_reason"` // Reason for blocking (e.g., "HWID limit exceeded")
// Legacy fields (deprecated, kept for backward compatibility)
FirstSeen int64 `json:"firstSeen,omitempty" gorm:"-"` // Deprecated: use FirstSeenAt
LastSeen int64 `json:"lastSeen,omitempty" gorm:"-"` // Deprecated: use LastSeenAt
}
// TableName specifies the table name for ClientHWID.
// GORM by default would use "client_hwids" but the actual table is "client_hw_ids"
func (ClientHWID) TableName() string {
return "client_hw_ids"
}

View file

@ -5,6 +5,10 @@ services:
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
container_name: 3xui_app container_name: 3xui_app
# hostname: yourhostname <- optional # hostname: yourhostname <- optional
# ports:
# - "2053:2053" # Web UI
# - "2096:2096" # Subscriptions
# - "443:443" # Example - Inbound internal
volumes: volumes:
- $PWD/db/:/etc/x-ui/ - $PWD/db/:/etc/x-ui/
- $PWD/cert/:/root/cert/ - $PWD/cert/:/root/cert/
@ -14,3 +18,4 @@ services:
tty: true tty: true
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped

14
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 (
@ -32,13 +36,16 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.2 // indirect github.com/cloudflare/circl v1.6.2 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
@ -70,6 +77,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect github.com/refraction-networking/utls v1.8.1 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
@ -86,6 +94,7 @@ require (
github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.23.0 // indirect
@ -101,3 +110,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 => ./

10
go.sum
View file

@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@ -12,6 +14,8 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@ -22,6 +26,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM=
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
@ -149,6 +155,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
@ -207,6 +215,8 @@ github.com/xtls/xray-core v1.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0e
github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4= github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

View file

@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
@ -69,12 +70,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
}
} }
} }
@ -202,6 +210,27 @@ func addToBuffer(level string, newLog string) {
level: logLevel, level: logLevel,
log: newLog, log: newLog,
}) })
// If running on node, push log to panel in real-time
// Check if we're in node mode by checking for NODE_API_KEY environment variable
if os.Getenv("NODE_API_KEY") != "" {
// Format log line as "timestamp level - message" for panel
logLine := fmt.Sprintf("%s %s - %s", t.Format(timeFormat), strings.ToUpper(level), newLog)
// Use build tag or lazy initialization to avoid circular dependency
// For now, we'll use a simple check - if node/logs package is available
pushLogToPanel(logLine)
}
}
// pushLogToPanel pushes a log line to the panel (called from node mode only).
// This function will be implemented in node package to avoid circular dependency.
var pushLogToPanel = func(logLine string) {
// Default: no-op, will be overridden by node package if available
}
// SetLogPusher sets the function to push logs to panel (called from node package).
func SetLogPusher(pusher func(string)) {
pushLogToPanel = pusher
} }
// GetLogs retrieves up to c log entries from the buffer that are at or below the specified level. // GetLogs retrieves up to c log entries from the buffer that are at or below the specified level.

View file

@ -50,6 +50,13 @@ func runWebServer() {
log.Fatalf("Error initializing database: %v", err) log.Fatalf("Error initializing database: %v", err)
} }
// Initialize Redis cache (embedded mode by default)
err = web.InitRedisCache("")
if err != nil {
log.Fatalf("Error initializing Redis cache: %v", err)
}
defer web.CloseRedisCache()
var server *web.Server var server *web.Server
server = web.NewServer() server = web.NewServer()
global.SetWebServer(server) global.SetWebServer(server)

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
```

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

@ -0,0 +1,303 @@
// Package api provides REST API endpoints for the node service.
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/logger"
nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config"
nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs"
"github.com/mhsanaei/3x-ui/v2/node/xray"
"github.com/gin-gonic/gin"
)
// 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)
// Registration endpoint (no auth required, used for initial setup)
router.POST("/api/v1/register", s.register)
// API endpoints (require auth)
api := router.Group("/api/v1")
{
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)
api.GET("/logs", s.getLogs)
api.GET("/service-logs", s.getServiceLogs)
}
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 and registration endpoints
if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" {
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
}
// Try to parse as JSON with optional panelUrl field
var requestData struct {
Config json.RawMessage `json:"config"`
PanelURL string `json:"panelUrl,omitempty"`
}
// First try to parse as new format with panelUrl
if err := json.Unmarshal(body, &requestData); err == nil && requestData.PanelURL != "" {
// New format: { "config": {...}, "panelUrl": "http://..." }
body = requestData.Config
// Set panel URL for log pusher
nodeLogs.SetPanelURL(requestData.PanelURL)
} else {
// Old format: just JSON config, validate it
var configJSON json.RawMessage
if err := json.Unmarshal(body, &configJSON); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
}
if err := s.xrayManager.ApplyConfig(body); err != nil {
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)
}
// getLogs returns XRAY access logs from the node.
func (s *Server) getLogs(c *gin.Context) {
// Get query parameters
countStr := c.DefaultQuery("count", "100")
filter := c.DefaultQuery("filter", "")
count, err := strconv.Atoi(countStr)
if err != nil || count < 1 || count > 10000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"})
return
}
logs, err := s.xrayManager.GetLogs(count, filter)
if err != nil {
logger.Errorf("Failed to get logs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
// getServiceLogs returns service application logs from the node (node service logs and XRAY core logs).
func (s *Server) getServiceLogs(c *gin.Context) {
// Get query parameters
countStr := c.DefaultQuery("count", "100")
level := c.DefaultQuery("level", "debug")
count, err := strconv.Atoi(countStr)
if err != nil || count < 1 || count > 10000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"})
return
}
// Get logs from logger buffer
logs := logger.GetLogs(count, level)
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
// register handles node registration from the panel.
// This endpoint receives an API key from the panel and saves it persistently.
// No authentication required - this is the initial setup step.
func (s *Server) register(c *gin.Context) {
type RegisterRequest struct {
ApiKey string `json:"apiKey" binding:"required"` // API key generated by panel
PanelURL string `json:"panelUrl,omitempty"` // Panel URL (optional)
NodeAddress string `json:"nodeAddress,omitempty"` // Node address (optional)
}
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Check if node is already registered
existingConfig := nodeConfig.GetConfig()
if existingConfig.ApiKey != "" {
logger.Warningf("Node is already registered. Rejecting registration attempt to prevent overwriting existing API key")
c.JSON(http.StatusConflict, gin.H{
"error": "Node is already registered. API key cannot be overwritten",
"message": "This node has already been registered. If you need to re-register, please remove the node-config.json file first",
})
return
}
// Save API key to config file (only if not already registered)
if err := nodeConfig.SetApiKey(req.ApiKey, false); err != nil {
logger.Errorf("Failed to save API key: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save API key: " + err.Error()})
return
}
// Update API key in server (for immediate use)
s.apiKey = req.ApiKey
// Save panel URL if provided
if req.PanelURL != "" {
if err := nodeConfig.SetPanelURL(req.PanelURL); err != nil {
logger.Warningf("Failed to save panel URL: %v", err)
} else {
// Update log pusher with new panel URL and API key
nodeLogs.SetPanelURL(req.PanelURL)
nodeLogs.UpdateApiKey(req.ApiKey) // Update API key in log pusher
}
} else {
// Even if panel URL is not provided, update API key in log pusher
nodeLogs.UpdateApiKey(req.ApiKey)
}
// Save node address if provided
if req.NodeAddress != "" {
if err := nodeConfig.SetNodeAddress(req.NodeAddress); err != nil {
logger.Warningf("Failed to save node address: %v", err)
}
}
logger.Infof("Node registered successfully with API key (length: %d)", len(req.ApiKey))
c.JSON(http.StatusOK, gin.H{
"message": "Node registered successfully",
"apiKey": req.ApiKey, // Return API key for confirmation
})
}

1
node/bin/config.json Normal file
View file

@ -0,0 +1 @@
#default

156
node/config/config.go Normal file
View file

@ -0,0 +1,156 @@
// Package config provides node configuration management, including API key persistence.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// NodeConfig represents the node's configuration stored on disk.
type NodeConfig struct {
ApiKey string `json:"apiKey"` // API key for authentication with panel
PanelURL string `json:"panelUrl"` // Panel URL (optional, can be set via env var)
NodeAddress string `json:"nodeAddress"` // Node's own address (optional)
}
var (
config *NodeConfig
configMu sync.RWMutex
configPath string
)
// InitConfig initializes the configuration system and loads existing config if available.
// configDir is the directory where config file will be stored (e.g., "bin", "/app/bin").
func InitConfig(configDir string) error {
configMu.Lock()
defer configMu.Unlock()
// Determine config file path
if configDir == "" {
// Try common paths
possibleDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"}
for _, dir := range possibleDirs {
if _, err := os.Stat(dir); err == nil {
configDir = dir
break
}
}
if configDir == "" {
configDir = "." // Fallback to current directory
}
}
configPath = filepath.Join(configDir, "node-config.json")
// Try to load existing config
if data, err := os.ReadFile(configPath); err == nil {
var loadedConfig NodeConfig
if err := json.Unmarshal(data, &loadedConfig); err == nil {
config = &loadedConfig
return nil
}
// If file exists but is invalid, we'll create a new one
}
// Create empty config if file doesn't exist
config = &NodeConfig{}
return nil
}
// GetConfig returns the current node configuration.
func GetConfig() *NodeConfig {
configMu.RLock()
defer configMu.RUnlock()
if config == nil {
return &NodeConfig{}
}
// Return a copy to prevent external modifications
return &NodeConfig{
ApiKey: config.ApiKey,
PanelURL: config.PanelURL,
NodeAddress: config.NodeAddress,
}
}
// SetApiKey sets the API key and saves it to disk.
// If an API key already exists, it will not be overwritten unless force is true.
func SetApiKey(apiKey string, force bool) error {
configMu.Lock()
defer configMu.Unlock()
if config == nil {
config = &NodeConfig{}
}
// Check if API key already exists
if config.ApiKey != "" && !force {
return fmt.Errorf("API key already exists. Use force=true to overwrite")
}
config.ApiKey = apiKey
return saveConfig()
}
// SetPanelURL sets the panel URL and saves it to disk.
func SetPanelURL(url string) error {
configMu.Lock()
defer configMu.Unlock()
if config == nil {
config = &NodeConfig{}
}
config.PanelURL = url
return saveConfig()
}
// SetNodeAddress sets the node address and saves it to disk.
func SetNodeAddress(address string) error {
configMu.Lock()
defer configMu.Unlock()
if config == nil {
config = &NodeConfig{}
}
config.NodeAddress = address
return saveConfig()
}
// saveConfig saves the current configuration to disk.
func saveConfig() error {
if configPath == "" {
return fmt.Errorf("config path not initialized, call InitConfig first")
}
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Marshal config to JSON
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Write to file with proper permissions (readable/writable by owner only)
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
// GetConfigPath returns the path to the config file.
func GetConfigPath() string {
configMu.RLock()
defer configMu.RUnlock()
return configPath
}

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

@ -0,0 +1,23 @@
services:
node:
build:
context: ..
dockerfile: node/Dockerfile
container_name: 3x-ui-node
restart: unless-stopped
#environment:
#- NODE_API_KEY=test-key
#- PANEL_URL=http://192.168.0.7:2054
ports:
- "8080:8080" # API ports (connect panel)
# - "44000:44000" # Xray ports = Inbound port
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

346
node/logs/pusher.go Normal file
View file

@ -0,0 +1,346 @@
// Package logs provides log pushing functionality for sending logs from node to panel in real-time.
package logs
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/logger"
)
// LogPusher sends logs to the panel in real-time.
type LogPusher struct {
panelURL string
apiKey string
nodeAddress string // Node's own address for identification
logBuffer []string
bufferMu sync.Mutex
client *http.Client
enabled bool
lastPush time.Time
pushTicker *time.Ticker
stopCh chan struct{}
}
var (
pusher *LogPusher
pusherOnce sync.Once
pusherMu sync.RWMutex
)
// InitLogPusher initializes the log pusher if panel URL and API key are configured.
// nodeAddress is the address of this node (e.g., "http://192.168.0.7:8080") for identification.
func InitLogPusher(nodeAddress string) {
pusherOnce.Do(func() {
// Try to get API key from (in order of priority):
// 1. Environment variable
// 2. Saved config file
apiKey := os.Getenv("NODE_API_KEY")
if apiKey == "" {
// Try to load from saved config
cfg := getNodeConfig()
if cfg != nil && cfg.ApiKey != "" {
apiKey = cfg.ApiKey
logger.Debug("Using API key from saved configuration for log pusher")
}
}
if apiKey == "" {
logger.Debug("Log pusher disabled: no API key found (will be enabled after registration)")
return
}
// Try to get panel URL from environment variable first, then from saved config
panelURL := os.Getenv("PANEL_URL")
if panelURL == "" {
cfg := getNodeConfig()
if cfg != nil && cfg.PanelURL != "" {
panelURL = cfg.PanelURL
logger.Debug("Using panel URL from saved configuration for log pusher")
}
}
pusher = &LogPusher{
panelURL: panelURL,
apiKey: apiKey,
nodeAddress: nodeAddress,
logBuffer: make([]string, 0, 10),
client: &http.Client{
Timeout: 5 * time.Second,
},
enabled: panelURL != "", // Enable only if panel URL is set
stopCh: make(chan struct{}),
}
if pusher.enabled {
// Start periodic push (every 2 seconds or when buffer is full)
pusher.pushTicker = time.NewTicker(2 * time.Second)
go pusher.run()
logger.Debugf("Log pusher initialized: sending logs to %s", panelURL)
} else {
logger.Debug("Log pusher initialized but disabled: waiting for panel URL")
}
})
}
// nodeConfigData represents the node configuration structure.
type nodeConfigData struct {
ApiKey string `json:"apiKey"`
PanelURL string `json:"panelUrl"`
NodeAddress string `json:"nodeAddress"`
}
// getNodeConfig is a helper to get node config without circular dependency.
// It reads the config file directly to avoid importing the config package.
func getNodeConfig() *nodeConfigData {
configPaths := []string{"bin/node-config.json", "config/node-config.json", "./node-config.json", "/app/bin/node-config.json", "/app/config/node-config.json"}
for _, path := range configPaths {
if data, err := os.ReadFile(path); err == nil {
var config nodeConfigData
if err := json.Unmarshal(data, &config); err == nil {
return &config
}
}
}
return nil
}
// SetPanelURL sets the panel URL and enables the log pusher.
// PANEL_URL from environment variable has priority and won't be overwritten.
func SetPanelURL(url string) {
pusherMu.Lock()
defer pusherMu.Unlock()
// Check if PANEL_URL is set in environment - it has priority
envPanelURL := os.Getenv("PANEL_URL")
if envPanelURL != "" {
// Environment variable has priority, ignore URL from config
if pusher != nil && pusher.panelURL == envPanelURL {
// Already set from env, don't update
return
}
// Use environment variable instead
url = envPanelURL
logger.Debugf("Using PANEL_URL from environment: %s (ignoring config URL)", envPanelURL)
}
if pusher == nil {
// Initialize if not already initialized
apiKey := os.Getenv("NODE_API_KEY")
if apiKey == "" {
// Try to load from saved config
cfg := getNodeConfig()
if cfg != nil && cfg.ApiKey != "" {
apiKey = cfg.ApiKey
}
}
if apiKey == "" {
logger.Debug("Cannot set panel URL: no API key found")
return
}
// Get node address from environment if not provided
nodeAddress := os.Getenv("NODE_ADDRESS")
if nodeAddress == "" {
cfg := getNodeConfig()
if cfg != nil && cfg.NodeAddress != "" {
nodeAddress = cfg.NodeAddress
}
}
pusher = &LogPusher{
apiKey: apiKey,
nodeAddress: nodeAddress,
logBuffer: make([]string, 0, 10),
client: &http.Client{
Timeout: 5 * time.Second,
},
stopCh: make(chan struct{}),
}
}
if url == "" {
logger.Debug("Panel URL cleared, disabling log pusher")
pusher.enabled = false
if pusher.pushTicker != nil {
pusher.pushTicker.Stop()
pusher.pushTicker = nil
}
return
}
wasEnabled := pusher.enabled
pusher.panelURL = url
pusher.enabled = true
if !wasEnabled && pusher.pushTicker == nil {
// Start periodic push if it wasn't running
pusher.pushTicker = time.NewTicker(2 * time.Second)
go pusher.run()
logger.Debugf("Log pusher enabled: sending logs to %s", url)
} else if wasEnabled && pusher.panelURL != url {
logger.Debugf("Log pusher panel URL updated: %s", url)
}
}
// UpdateApiKey updates the API key in the log pusher.
// This is called after node registration to enable log pushing.
func UpdateApiKey(apiKey string) {
pusherMu.Lock()
defer pusherMu.Unlock()
if pusher == nil {
logger.Debug("Cannot update API key: log pusher not initialized")
return
}
pusher.apiKey = apiKey
logger.Debugf("Log pusher API key updated (length: %d)", len(apiKey))
// If pusher is enabled but wasn't running, start it
if pusher.enabled && pusher.pushTicker == nil && pusher.panelURL != "" {
pusher.pushTicker = time.NewTicker(2 * time.Second)
go pusher.run()
logger.Debugf("Log pusher started after API key update")
}
}
// PushLog adds a log entry to the buffer for sending to panel.
func PushLog(logLine string) {
if pusher == nil || !pusher.enabled {
return
}
// Skip logs that already contain node prefix to avoid infinite loop
// These are logs that came from panel and shouldn't be sent back
if strings.Contains(logLine, "[Node:") {
return
}
// Skip logs about log pushing itself to avoid infinite loop
if strings.Contains(logLine, "Logs pushed:") || strings.Contains(logLine, "Failed to push logs") {
return
}
pusher.bufferMu.Lock()
defer pusher.bufferMu.Unlock()
pusher.logBuffer = append(pusher.logBuffer, logLine)
// If buffer is getting large, push immediately
if len(pusher.logBuffer) >= 10 {
go pusher.push()
}
}
// run periodically pushes logs to panel.
func (lp *LogPusher) run() {
for {
select {
case <-lp.pushTicker.C:
lp.bufferMu.Lock()
if len(lp.logBuffer) > 0 {
logsToPush := make([]string, len(lp.logBuffer))
copy(logsToPush, lp.logBuffer)
lp.logBuffer = lp.logBuffer[:0]
lp.bufferMu.Unlock()
go lp.pushLogs(logsToPush)
} else {
lp.bufferMu.Unlock()
}
case <-lp.stopCh:
return
}
}
}
// push immediately pushes current buffer to panel.
func (lp *LogPusher) push() {
lp.bufferMu.Lock()
if len(lp.logBuffer) == 0 {
lp.bufferMu.Unlock()
return
}
logsToPush := make([]string, len(lp.logBuffer))
copy(logsToPush, lp.logBuffer)
lp.logBuffer = lp.logBuffer[:0]
lp.bufferMu.Unlock()
lp.pushLogs(logsToPush)
}
// pushLogs sends logs to the panel.
func (lp *LogPusher) pushLogs(logs []string) {
if len(logs) == 0 {
return
}
// Construct panel URL
panelEndpoint := lp.panelURL
if panelEndpoint[len(panelEndpoint)-1] != '/' {
panelEndpoint += "/"
}
panelEndpoint += "panel/api/node/push-logs"
// Log push attempt (DEBUG level to avoid sending this log back to panel)
logger.Debugf("Logs pushed: %d log entries to %s", len(logs), panelEndpoint)
// Prepare request
reqBody := map[string]interface{}{
"apiKey": lp.apiKey,
"logs": logs,
}
// Add node address for identification (in case multiple nodes share the same API key)
if lp.nodeAddress != "" {
reqBody["nodeAddress"] = lp.nodeAddress
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
logger.Errorf("Failed to marshal log push request to %s: %v", panelEndpoint, err)
return
}
req, err := http.NewRequest("POST", panelEndpoint, bytes.NewBuffer(jsonData))
if err != nil {
logger.Errorf("Failed to create log push request to %s: %v", panelEndpoint, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := lp.client.Do(req)
if err != nil {
logger.Errorf("Failed to push logs to panel at %s: %v (check if panel URL is correct and accessible)", panelEndpoint, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.Errorf("Panel at %s returned non-OK status %d for log push: %s", panelEndpoint, resp.StatusCode, string(body))
return
}
lp.lastPush = time.Now()
}
// Stop stops the log pusher.
func Stop() {
if pusher != nil && pusher.pushTicker != nil {
pusher.pushTicker.Stop()
close(pusher.stopCh)
// Push remaining logs
pusher.push()
}
}

114
node/main.go Normal file
View file

@ -0,0 +1,114 @@
// 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"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/node/api"
nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config"
nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs"
"github.com/mhsanaei/3x-ui/v2/node/xray"
"github.com/op/go-logging"
)
func main() {
var port int
var apiKey string
flag.IntVar(&port, "port", 8080, "API server port")
flag.StringVar(&apiKey, "api-key", "", "API key for authentication (optional, can be set via registration)")
flag.Parse()
logger.InitLogger(logging.INFO)
// Initialize node configuration system
// Try to find config directory (same as XRAY config)
configDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"}
var configDir string
for _, dir := range configDirs {
if _, err := os.Stat(dir); err == nil {
configDir = dir
break
}
}
if configDir == "" {
configDir = "." // Fallback
}
if err := nodeConfig.InitConfig(configDir); err != nil {
log.Fatalf("Failed to initialize node config: %v", err)
}
// Get API key from (in order of priority):
// 1. Command line flag
// 2. Environment variable (for backward compatibility)
// 3. Saved config file (from registration)
if apiKey == "" {
apiKey = os.Getenv("NODE_API_KEY")
}
if apiKey == "" {
// Try to load from saved config
savedConfig := nodeConfig.GetConfig()
if savedConfig.ApiKey != "" {
apiKey = savedConfig.ApiKey
log.Printf("Using API key from saved configuration")
}
}
// If still no API key, node can start but will need registration
if apiKey == "" {
log.Printf("WARNING: No API key found. Node will need to be registered via /api/v1/register endpoint")
log.Printf("You can set NODE_API_KEY environment variable or use -api-key flag for immediate use")
// Use a temporary key that will be replaced during registration
apiKey = "temp-unregistered"
}
// Initialize log pusher if panel URL is configured
// Get node address from saved config or environment variable
savedConfig := nodeConfig.GetConfig()
nodeAddress := savedConfig.NodeAddress
if nodeAddress == "" {
nodeAddress = os.Getenv("NODE_ADDRESS")
}
if nodeAddress == "" {
// Default to localhost with the port (panel will match by port if address doesn't match exactly)
nodeAddress = fmt.Sprintf("http://127.0.0.1:%d", port)
}
// Get panel URL from saved config or environment variable
panelURL := savedConfig.PanelURL
if panelURL == "" {
panelURL = os.Getenv("PANEL_URL")
}
nodeLogs.InitLogPusher(nodeAddress)
if panelURL != "" {
nodeLogs.SetPanelURL(panelURL)
}
// Connect log pusher to logger
logger.SetLogPusher(nodeLogs.PushLog)
xrayManager := xray.NewManager()
server := api.NewServer(port, apiKey, xrayManager)
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")
}

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

@ -0,0 +1,541 @@
// Package xray provides XRAY Core management for the node service.
package xray
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"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
}
// GetLogs returns XRAY access logs from the log file.
// Returns raw log lines as strings.
func (m *Manager) GetLogs(count int, filter string) ([]string, error) {
m.lock.Lock()
defer m.lock.Unlock()
if m.process == nil || !m.process.IsRunning() {
return nil, errors.New("XRAY is not running")
}
// Get access log path from current config
var pathToAccessLog string
if m.config != nil && len(m.config.LogConfig) > 0 {
var logConfig map[string]interface{}
if err := json.Unmarshal(m.config.LogConfig, &logConfig); err == nil {
if access, ok := logConfig["access"].(string); ok {
pathToAccessLog = access
}
}
}
// Fallback to reading from file if not in config
if pathToAccessLog == "" {
var err error
pathToAccessLog, err = xray.GetAccessLogPath()
if err != nil {
return nil, fmt.Errorf("failed to get access log path: %w", err)
}
}
if pathToAccessLog == "none" || pathToAccessLog == "" {
return []string{}, nil // No logs configured
}
file, err := os.Open(pathToAccessLog)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.Contains(line, "api -> api") {
continue // Skip empty lines and API calls
}
if filter != "" && !strings.Contains(line, filter) {
continue // Apply filter if provided
}
lines = append(lines, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read log file: %w", err)
}
// Return last 'count' lines
if len(lines) > count {
lines = lines[len(lines)-count:]
}
return lines, nil
}

View file

@ -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,10 @@ func NewSUBController(
subTitle string, subTitle string,
) *SUBController { ) *SUBController {
sub := NewSubService(showInfo, rModel) sub := NewSubService(showInfo, rModel)
// Initialize services for multi-node support and new architecture
sub.nodeService = service.NodeService{}
sub.hostService = service.HostService{}
sub.clientService = service.ClientService{}
a := &SUBController{ a := &SUBController{
subTitle: subTitle, subTitle: subTitle,
subPath: subPath, subPath: subPath,
@ -70,7 +75,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
func (a *SUBController) subs(c *gin.Context) { func (a *SUBController) subs(c *gin.Context) {
subId := c.Param("subid") subId := c.Param("subid")
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host, c) // Pass context for HWID registration
if err != nil || len(subs) == 0 { if err != nil || len(subs) == 0 {
c.String(400, "Error!") c.String(400, "Error!")
} else { } else {
@ -127,7 +132,7 @@ func (a *SUBController) subs(c *gin.Context) {
// Add headers // Add headers
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId)
if a.subEncrypt { if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@ -141,21 +146,24 @@ func (a *SUBController) subs(c *gin.Context) {
func (a *SUBController) subJsons(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) {
subId := c.Param("subid") subId := c.Param("subid")
_, host, _, _ := a.subService.ResolveRequest(c) _, host, _, _ := a.subService.ResolveRequest(c)
jsonSub, header, err := a.subJsonService.GetJson(subId, host) jsonSub, header, err := a.subJsonService.GetJson(subId, host, c) // Pass context for HWID registration
if err != nil || len(jsonSub) == 0 { if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!") c.String(400, "Error!")
} else { } else {
// Add headers // Add headers
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId)
c.String(200, jsonSub) c.String(200, jsonSub)
} }
} }
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) { // Also adds X-Subscription-ID header so clients can use it as HWID if needed.
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle, subId string) {
c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Subscription-Userinfo", header)
c.Writer.Header().Set("Profile-Update-Interval", updateInterval) c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle))) c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
// Add subscription ID header so clients can use it as HWID identifier
c.Writer.Header().Set("X-Subscription-ID", subId)
} }

View file

@ -7,6 +7,8 @@ import (
"maps" "maps"
"strings" "strings"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/json_util" "github.com/mhsanaei/3x-ui/v2/util/json_util"
@ -71,7 +73,19 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
} }
// GetJson generates a JSON subscription configuration for the given subscription ID and host. // GetJson generates a JSON subscription configuration for the given subscription ID and host.
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { // If gin.Context is provided, it will also register HWID from HTTP headers.
func (s *SubJsonService) GetJson(subId string, host string, c *gin.Context) (string, string, error) {
// Register HWID from headers if context is provided
if c != nil {
// Try to find client by subId
db := database.GetDB()
var clientEntity *model.ClientEntity
err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error
if err == nil && clientEntity != nil {
s.SubService.registerHWIDFromRequest(c, clientEntity)
}
}
inbounds, err := s.SubService.getInboundsBySubId(subId) inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 { if err != nil || len(inbounds) == 0 {
return "", "", err return "", "", err

File diff suppressed because it is too large Load diff

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;
} }
} }
@ -1764,7 +1902,7 @@ Inbound.Settings = class extends XrayCommonClass {
Inbound.VmessSettings = class extends Inbound.Settings { Inbound.VmessSettings = class extends Inbound.Settings {
constructor(protocol, constructor(protocol,
vmesses = [new Inbound.VmessSettings.VMESS()]) { vmesses = []) {
super(protocol); super(protocol);
this.vmesses = vmesses; this.vmesses = vmesses;
} }
@ -1880,7 +2018,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass {
Inbound.VLESSSettings = class extends Inbound.Settings { Inbound.VLESSSettings = class extends Inbound.Settings {
constructor( constructor(
protocol, protocol,
vlesses = [new Inbound.VLESSSettings.VLESS()], vlesses = [],
decryption = "none", decryption = "none",
encryption = "none", encryption = "none",
fallbacks = [], fallbacks = [],
@ -2070,7 +2208,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
Inbound.TrojanSettings = class extends Inbound.Settings { Inbound.TrojanSettings = class extends Inbound.Settings {
constructor(protocol, constructor(protocol,
trojans = [new Inbound.TrojanSettings.Trojan()], trojans = [],
fallbacks = [],) { fallbacks = [],) {
super(protocol); super(protocol);
this.trojans = trojans; this.trojans = trojans;
@ -2235,7 +2373,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
method = SSMethods.BLAKE3_AES_256_GCM, method = SSMethods.BLAKE3_AES_256_GCM,
password = RandomUtil.randomShadowsocksPassword(), password = RandomUtil.randomShadowsocksPassword(),
network = 'tcp,udp', network = 'tcp,udp',
shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()], shadowsockses = [],
ivCheck = false, ivCheck = false,
) { ) {
super(protocol); super(protocol);

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,42 @@ 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
// HWID tracking mode
// "off" = HWID tracking disabled
// "client_header" = HWID provided by client via x-hwid header (default, recommended)
// "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only)
this.hwidMode = "client_header"; // HWID tracking mode
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;
}
// Ensure hwidMode is valid string (default to "client_header" if invalid)
if (this.hwidMode === undefined || this.hwidMode === null) {
this.hwidMode = "client_header";
} else if (typeof this.hwidMode !== 'string') {
this.hwidMode = String(this.hwidMode);
}
// Validate hwidMode value
const validHwidModes = ["off", "client_header", "legacy_fingerprint"];
if (!validHwidModes.includes(this.hwidMode)) {
this.hwidMode = "client_header"; // Default to client_header if invalid
}
} }
equals(other) { equals(other) {

123
web/cache/cache.go vendored Normal file
View file

@ -0,0 +1,123 @@
// Package cache provides caching utilities with JSON serialization support.
package cache
import (
"encoding/json"
"fmt"
"time"
"github.com/mhsanaei/3x-ui/v2/logger"
)
const (
// Default TTL values
TTLInbounds = 30 * time.Second
TTLClients = 30 * time.Second
TTLSettings = 5 * time.Minute
TTLSetting = 10 * time.Minute // Increased from 5 to 10 minutes for better cache hit rate
)
// Cache keys
const (
KeyInboundsPrefix = "inbounds:user:"
KeyClientsPrefix = "clients:user:"
KeySettingsAll = "settings:all"
KeySettingPrefix = "setting:"
)
// GetJSON retrieves a value from cache and unmarshals it as JSON.
func GetJSON(key string, dest interface{}) error {
val, err := Get(key)
if err != nil {
// Check if it's a "key not found" error (redis.Nil)
// This is expected and not a real error
if err.Error() == "redis: nil" {
return fmt.Errorf("key not found: %s", key)
}
return err
}
if val == "" {
return fmt.Errorf("empty value for key: %s", key)
}
return json.Unmarshal([]byte(val), dest)
}
// SetJSON marshals a value as JSON and stores it in cache.
func SetJSON(key string, value interface{}, expiration time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal value: %w", err)
}
return Set(key, string(data), expiration)
}
// GetOrSet retrieves a value from cache, or computes it using fn if not found.
func GetOrSet(key string, dest interface{}, expiration time.Duration, fn func() (interface{}, error)) error {
// Try to get from cache
err := GetJSON(key, dest)
if err == nil {
logger.Debugf("Cache hit for key: %s", key)
return nil
}
// Cache miss, compute value
logger.Debugf("Cache miss for key: %s", key)
value, err := fn()
if err != nil {
return err
}
// Store in cache
if err := SetJSON(key, value, expiration); err != nil {
logger.Warningf("Failed to set cache for key %s: %v", key, err)
}
// Copy value to dest
data, err := json.Marshal(value)
if err != nil {
return err
}
return json.Unmarshal(data, dest)
}
// InvalidateInbounds invalidates all inbounds cache for a user.
func InvalidateInbounds(userId int) error {
pattern := fmt.Sprintf("%s%d", KeyInboundsPrefix, userId)
return DeletePattern(pattern)
}
// InvalidateAllInbounds invalidates all inbounds cache.
func InvalidateAllInbounds() error {
pattern := KeyInboundsPrefix + "*"
return DeletePattern(pattern)
}
// InvalidateClients invalidates all clients cache for a user.
func InvalidateClients(userId int) error {
pattern := fmt.Sprintf("%s%d", KeyClientsPrefix, userId)
return DeletePattern(pattern)
}
// InvalidateAllClients invalidates all clients cache.
func InvalidateAllClients() error {
pattern := KeyClientsPrefix + "*"
return DeletePattern(pattern)
}
// InvalidateSetting invalidates a specific setting cache.
// Note: We don't invalidate KeySettingsAll here to avoid unnecessary cache misses.
// KeySettingsAll will be invalidated only when settings are actually changed.
func InvalidateSetting(key string) error {
settingKey := KeySettingPrefix + key
return Delete(settingKey)
}
// InvalidateAllSettings invalidates all settings cache.
func InvalidateAllSettings() error {
if err := Delete(KeySettingsAll); err != nil {
return err
}
// Also invalidate all individual settings
pattern := KeySettingPrefix + "*"
return DeletePattern(pattern)
}

137
web/cache/redis.go vendored Normal file
View file

@ -0,0 +1,137 @@
// Package cache provides Redis caching functionality for the 3x-ui web panel.
// It supports both embedded Redis (miniredis) and external Redis server.
package cache
import (
"context"
"fmt"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/redis/go-redis/v9"
)
var (
client *redis.Client
miniRedis *miniredis.Miniredis
ctx = context.Background()
isEmbedded = true
)
// InitRedis initializes Redis client. If redisAddr is empty, starts embedded Redis.
// If redisAddr is provided, connects to external Redis server.
func InitRedis(redisAddr string) error {
if redisAddr == "" {
// Use embedded Redis
mr, err := miniredis.Run()
if err != nil {
return fmt.Errorf("failed to start embedded Redis: %w", err)
}
miniRedis = mr
client = redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
isEmbedded = true
logger.Info("Embedded Redis started on", mr.Addr())
} else {
// Use external Redis
client = redis.NewClient(&redis.Options{
Addr: redisAddr,
Password: "", // Can be extended to support password
DB: 0,
})
isEmbedded = false
// Test connection
_, err := client.Ping(ctx).Result()
if err != nil {
return fmt.Errorf("failed to connect to Redis at %s: %w", redisAddr, err)
}
logger.Info("Connected to external Redis at", redisAddr)
}
return nil
}
// GetClient returns the Redis client instance.
func GetClient() *redis.Client {
return client
}
// IsEmbedded returns true if using embedded Redis.
func IsEmbedded() bool {
return isEmbedded
}
// Close closes the Redis connection and stops embedded Redis if running.
func Close() error {
if client != nil {
if err := client.Close(); err != nil {
return err
}
}
if miniRedis != nil {
miniRedis.Close()
}
return nil
}
// Set stores a value in Redis with expiration.
func Set(key string, value interface{}, expiration time.Duration) error {
if client == nil {
return fmt.Errorf("Redis client not initialized")
}
return client.Set(ctx, key, value, expiration).Err()
}
// Get retrieves a value from Redis.
func Get(key string) (string, error) {
if client == nil {
return "", fmt.Errorf("Redis client not initialized")
}
result, err := client.Get(ctx, key).Result()
if err == redis.Nil {
// Key doesn't exist - this is expected, not an error
return "", fmt.Errorf("redis: nil")
}
return result, err
}
// Delete removes a key from Redis.
func Delete(key string) error {
if client == nil {
return fmt.Errorf("Redis client not initialized")
}
return client.Del(ctx, key).Err()
}
// DeletePattern removes all keys matching a pattern.
func DeletePattern(pattern string) error {
if client == nil {
return fmt.Errorf("Redis client not initialized")
}
iter := client.Scan(ctx, 0, pattern, 0).Iterator()
keys := make([]string, 0)
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil {
return err
}
if len(keys) > 0 {
return client.Del(ctx, keys...).Err()
}
return nil
}
// Exists checks if a key exists in Redis.
func Exists(key string) (bool, error) {
if client == nil {
return false, fmt.Errorf("Redis client not initialized")
}
count, err := client.Exists(ctx, key).Result()
return count > 0, err
}

176
web/cache/redisstore.go vendored Normal file
View file

@ -0,0 +1,176 @@
// Package cache provides Redis store for gin sessions.
package cache
import (
"bytes"
"context"
"encoding/base32"
"encoding/gob"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-contrib/sessions"
gorillasessions "github.com/gorilla/sessions"
"github.com/gorilla/securecookie"
"github.com/redis/go-redis/v9"
)
const (
defaultMaxAge = 86400 * 7 // 7 days
)
// RedisStore stores sessions in Redis.
type RedisStore struct {
client *redis.Client
Codecs []securecookie.Codec
options *sessions.Options
}
// NewRedisStore creates a new Redis store.
func NewRedisStore(client *redis.Client, keyPairs ...[]byte) *RedisStore {
rs := &RedisStore{
client: client,
Codecs: securecookie.CodecsFromPairs(keyPairs...),
options: &sessions.Options{
Path: "/",
MaxAge: defaultMaxAge,
},
}
return rs
}
// Options sets the options for the store.
func (s *RedisStore) Options(opts sessions.Options) {
s.options = &opts
}
// Get retrieves a session from Redis.
func (s *RedisStore) Get(r *http.Request, name string) (*gorillasessions.Session, error) {
return gorillasessions.GetRegistry(r).Get(s, name)
}
// New creates a new session.
func (s *RedisStore) New(r *http.Request, name string) (*gorillasessions.Session, error) {
session := gorillasessions.NewSession(s, name)
session.Options = &gorillasessions.Options{
Path: s.options.Path,
Domain: s.options.Domain,
MaxAge: s.options.MaxAge,
Secure: s.options.Secure,
HttpOnly: s.options.HttpOnly,
SameSite: s.options.SameSite,
}
session.IsNew = true
// Try to load existing session from cookie
if c, errCookie := r.Cookie(name); errCookie == nil {
err := securecookie.DecodeMulti(name, c.Value, &session.ID, s.Codecs...)
if err == nil {
// Successfully decoded session ID, try to load from Redis
err = s.load(session)
if err == nil {
session.IsNew = false
}
// If load fails, continue with new session (session.IsNew = true)
}
// If decode fails (e.g., old cookie format), ignore and create new session
}
return session, nil
}
// Save saves a session to Redis.
func (s *RedisStore) Save(r *http.Request, w http.ResponseWriter, session *gorillasessions.Session) error {
// Delete if max age is < 0
if session.Options.MaxAge < 0 {
if err := s.delete(session); err != nil {
return err
}
http.SetCookie(w, s.newCookie(session, ""))
return nil
}
if session.ID == "" {
session.ID = strings.TrimRight(
base32.StdEncoding.EncodeToString(
securecookie.GenerateRandomKey(32),
), "=")
}
if err := s.save(session); err != nil {
return err
}
encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, s.Codecs...)
if err != nil {
return err
}
http.SetCookie(w, s.newCookie(session, encoded))
return nil
}
// newCookie creates a new HTTP cookie for the session.
func (s *RedisStore) newCookie(session *gorillasessions.Session, value string) *http.Cookie {
cookie := &http.Cookie{
Name: session.Name(),
Value: value,
Path: session.Options.Path,
Domain: session.Options.Domain,
MaxAge: session.Options.MaxAge,
Secure: session.Options.Secure,
HttpOnly: session.Options.HttpOnly,
SameSite: session.Options.SameSite,
}
if session.Options.MaxAge > 0 {
cookie.Expires = time.Now().Add(time.Duration(session.Options.MaxAge) * time.Second)
}
return cookie
}
// save stores session data in Redis.
func (s *RedisStore) save(session *gorillasessions.Session) error {
// Use gob encoding to preserve types (especially for model.User)
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(session.Values); err != nil {
return fmt.Errorf("failed to encode session values: %w", err)
}
maxAge := session.Options.MaxAge
if maxAge == 0 {
maxAge = s.options.MaxAge
}
key := fmt.Sprintf("session:%s", session.ID)
return s.client.Set(context.Background(), key, buf.Bytes(), time.Duration(maxAge)*time.Second).Err()
}
// load retrieves session data from Redis.
func (s *RedisStore) load(session *gorillasessions.Session) error {
key := fmt.Sprintf("session:%s", session.ID)
data, err := s.client.Get(context.Background(), key).Bytes()
if err == redis.Nil {
return fmt.Errorf("session not found")
}
if err != nil {
return err
}
// Use gob decoding to preserve types (especially for model.User)
buf := bytes.NewBuffer(data)
dec := gob.NewDecoder(buf)
if err := dec.Decode(&session.Values); err != nil {
return fmt.Errorf("failed to decode session data: %w", err)
}
return nil
}
// delete removes session from Redis.
func (s *RedisStore) delete(session *gorillasessions.Session) error {
key := fmt.Sprintf("session:%s", session.ID)
return s.client.Del(context.Background(), key).Err()
}

View file

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

467
web/controller/client.go Normal file
View file

@ -0,0 +1,467 @@
// Package controller provides HTTP handlers for client management.
package controller
import (
"bytes"
"encoding/json"
"io"
"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/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
// ClientController handles HTTP requests related to client management.
type ClientController struct {
clientService service.ClientService
xrayService service.XrayService
}
// NewClientController creates a new ClientController and sets up its routes.
func NewClientController(g *gin.RouterGroup) *ClientController {
a := &ClientController{
clientService: service.ClientService{},
xrayService: service.XrayService{},
}
a.initRouter(g)
return a
}
// initRouter initializes the routes for client-related operations.
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getClients)
g.GET("/get/:id", a.getClient)
g.POST("/add", a.addClient)
g.POST("/update/:id", a.updateClient)
g.POST("/del/:id", a.deleteClient)
g.POST("/resetAllTraffics", a.resetAllClientTraffics)
g.POST("/resetTraffic/:id", a.resetClientTraffic)
g.POST("/delDepletedClients", a.delDepletedClients)
}
// getClients retrieves the list of all clients for the current user.
func (a *ClientController) getClients(c *gin.Context) {
user := session.GetLoginUser(c)
clients, err := a.clientService.GetClients(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, clients, nil)
}
// getClient retrieves a specific client by its ID.
func (a *ClientController) getClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid client ID", err)
return
}
user := session.GetLoginUser(c)
client, err := a.clientService.GetClient(id)
if err != nil {
jsonMsg(c, "Failed to get client", err)
return
}
if client.UserId != user.Id {
jsonMsg(c, "Client not found or access denied", nil)
return
}
jsonObj(c, client, nil)
}
// addClient creates a new client.
func (a *ClientController) addClient(c *gin.Context) {
user := session.GetLoginUser(c)
// Extract inboundIds from JSON or form data
var inboundIdsFromJSON []int
var hasInboundIdsInJSON bool
if c.ContentType() == "application/json" {
// Read raw body to extract inboundIds
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
// Parse JSON to extract inboundIds
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
// Check for inboundIds array
if inboundIdsVal, ok := jsonData["inboundIds"]; ok {
hasInboundIdsInJSON = true
if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok {
for _, val := range inboundIdsArray {
if num, ok := val.(float64); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := val.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
} else if num, ok := inboundIdsVal.(float64); ok {
// Single number instead of array
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := inboundIdsVal.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
}
// Restore body for ShouldBind
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
client := &model.ClientEntity{}
err := c.ShouldBind(client)
if err != nil {
jsonMsg(c, "Invalid client data", err)
return
}
// Set inboundIds from JSON if available
if hasInboundIdsInJSON {
client.InboundIds = inboundIdsFromJSON
} else {
// Try to get from form data
inboundIdsStr := c.PostFormArray("inboundIds")
if len(inboundIdsStr) > 0 {
var inboundIds []int
for _, idStr := range inboundIdsStr {
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
inboundIds = append(inboundIds, id)
}
}
}
client.InboundIds = inboundIds
}
}
needRestart, err := a.clientService.AddClient(user.Id, client)
if err != nil {
logger.Errorf("Failed to add client: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientCreateSuccess"), client, nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after client creation: %v", err)
}
}
}
// updateClient updates an existing client.
func (a *ClientController) updateClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid client ID", err)
return
}
user := session.GetLoginUser(c)
// Get existing client first to preserve fields not being updated
existing, err := a.clientService.GetClient(id)
if err != nil {
jsonMsg(c, "Client not found", err)
return
}
if existing.UserId != user.Id {
jsonMsg(c, "Client not found or access denied", nil)
return
}
// Extract inboundIds from JSON or form data
var inboundIdsFromJSON []int
var hasInboundIdsInJSON bool
if c.ContentType() == "application/json" {
// Read raw body to extract inboundIds
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
// Parse JSON to extract inboundIds
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
// Check for inboundIds array
if inboundIdsVal, ok := jsonData["inboundIds"]; ok {
hasInboundIdsInJSON = true
if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok {
for _, val := range inboundIdsArray {
if num, ok := val.(float64); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := val.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
} else if num, ok := inboundIdsVal.(float64); ok {
// Single number instead of array
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := inboundIdsVal.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
}
// Restore body for ShouldBind
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
// Use existing client as base and update only provided fields
client := existing
// Try to bind only provided fields - use ShouldBindJSON for JSON requests
if c.ContentType() == "application/json" {
var updateData map[string]interface{}
if err := c.ShouldBindJSON(&updateData); err == nil {
// Update only fields that are present in the request
if email, ok := updateData["email"].(string); ok && email != "" {
client.Email = email
}
if uuid, ok := updateData["uuid"].(string); ok && uuid != "" {
client.UUID = uuid
}
if security, ok := updateData["security"].(string); ok && security != "" {
client.Security = security
}
if password, ok := updateData["password"].(string); ok && password != "" {
client.Password = password
}
if flow, ok := updateData["flow"].(string); ok && flow != "" {
client.Flow = flow
}
if limitIP, ok := updateData["limitIp"].(float64); ok {
client.LimitIP = int(limitIP)
} else if limitIP, ok := updateData["limitIp"].(int); ok {
client.LimitIP = limitIP
}
if totalGB, ok := updateData["totalGB"].(float64); ok {
client.TotalGB = totalGB
} else if totalGB, ok := updateData["totalGB"].(int); ok {
client.TotalGB = float64(totalGB)
} else if totalGB, ok := updateData["totalGB"].(int64); ok {
client.TotalGB = float64(totalGB)
}
if expiryTime, ok := updateData["expiryTime"].(float64); ok {
client.ExpiryTime = int64(expiryTime)
} else if expiryTime, ok := updateData["expiryTime"].(int64); ok {
client.ExpiryTime = expiryTime
}
if enable, ok := updateData["enable"].(bool); ok {
client.Enable = enable
}
if tgID, ok := updateData["tgId"].(float64); ok {
client.TgID = int64(tgID)
} else if tgID, ok := updateData["tgId"].(int64); ok {
client.TgID = tgID
}
if subID, ok := updateData["subId"].(string); ok && subID != "" {
client.SubID = subID
}
if comment, ok := updateData["comment"].(string); ok && comment != "" {
client.Comment = comment
}
if reset, ok := updateData["reset"].(float64); ok {
client.Reset = int(reset)
} else if reset, ok := updateData["reset"].(int); ok {
client.Reset = reset
}
if hwidEnabled, ok := updateData["hwidEnabled"].(bool); ok {
client.HWIDEnabled = hwidEnabled
}
if maxHwid, ok := updateData["maxHwid"].(float64); ok {
client.MaxHWID = int(maxHwid)
} else if maxHwid, ok := updateData["maxHwid"].(int); ok {
client.MaxHWID = maxHwid
}
}
} else {
// For form data, use ShouldBind
updateClient := &model.ClientEntity{}
if err := c.ShouldBind(updateClient); err == nil {
// Update only non-empty fields
if updateClient.Email != "" {
client.Email = updateClient.Email
}
if updateClient.UUID != "" {
client.UUID = updateClient.UUID
}
if updateClient.Security != "" {
client.Security = updateClient.Security
}
if updateClient.Password != "" {
client.Password = updateClient.Password
}
if updateClient.Flow != "" {
client.Flow = updateClient.Flow
}
if updateClient.LimitIP > 0 {
client.LimitIP = updateClient.LimitIP
}
if updateClient.TotalGB > 0 {
client.TotalGB = updateClient.TotalGB
}
if updateClient.ExpiryTime != 0 {
client.ExpiryTime = updateClient.ExpiryTime
}
// Always update enable if it's in the request (even if false)
enableStr := c.PostForm("enable")
if enableStr != "" {
client.Enable = enableStr == "true" || enableStr == "1"
}
if updateClient.TgID > 0 {
client.TgID = updateClient.TgID
}
if updateClient.SubID != "" {
client.SubID = updateClient.SubID
}
if updateClient.Comment != "" {
client.Comment = updateClient.Comment
}
if updateClient.Reset > 0 {
client.Reset = updateClient.Reset
}
}
}
// Set inboundIds from JSON if available
if hasInboundIdsInJSON {
client.InboundIds = inboundIdsFromJSON
logger.Debugf("UpdateClient: extracted inboundIds from JSON: %v", inboundIdsFromJSON)
} else {
// Try to get from form data
inboundIdsStr := c.PostFormArray("inboundIds")
if len(inboundIdsStr) > 0 {
var inboundIds []int
for _, idStr := range inboundIdsStr {
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
inboundIds = append(inboundIds, id)
}
}
}
client.InboundIds = inboundIds
logger.Debugf("UpdateClient: extracted inboundIds from form: %v", inboundIds)
} else {
logger.Debugf("UpdateClient: inboundIds not provided, keeping existing assignments")
}
}
client.Id = id
logger.Debugf("UpdateClient: client.InboundIds = %v", client.InboundIds)
needRestart, err := a.clientService.UpdateClient(user.Id, client)
if err != nil {
logger.Errorf("Failed to update client: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientUpdateSuccess"), client, nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after client update: %v", err)
}
}
}
// deleteClient deletes a client by ID.
func (a *ClientController) deleteClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid client ID", err)
return
}
user := session.GetLoginUser(c)
needRestart, err := a.clientService.DeleteClient(user.Id, id)
if err != nil {
logger.Errorf("Failed to delete client: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.clients.toasts.clientDeleteSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after client deletion: %v", err)
}
}
}
// resetAllClientTraffics resets traffic counters for all clients of the current user.
func (a *ClientController) resetAllClientTraffics(c *gin.Context) {
user := session.GetLoginUser(c)
needRestart, err := a.clientService.ResetAllClientTraffics(user.Id)
if err != nil {
logger.Errorf("Failed to reset all client traffics: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after resetting all client traffics: %v", err)
}
}
}
// resetClientTraffic resets traffic counter for a specific client.
func (a *ClientController) resetClientTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid client ID", err)
return
}
user := session.GetLoginUser(c)
needRestart, err := a.clientService.ResetClientTraffic(user.Id, id)
if err != nil {
logger.Errorf("Failed to reset client traffic: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after client traffic reset: %v", err)
}
}
}
// delDepletedClients deletes clients that have exhausted their traffic limits or expired.
func (a *ClientController) delDepletedClients(c *gin.Context) {
user := session.GetLoginUser(c)
count, needRestart, err := a.clientService.DelDepletedClients(user.Id)
if err != nil {
logger.Errorf("Failed to delete depleted clients: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if count > 0 {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after deleting depleted clients: %v", err)
}
}
} else {
jsonMsg(c, "No depleted clients found", nil)
}
}

View file

@ -0,0 +1,224 @@
// Package controller provides HTTP handlers for client HWID management.
package controller
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// ClientHWIDController handles HTTP requests for client HWID management.
type ClientHWIDController struct {
clientHWIDService *service.ClientHWIDService
clientService *service.ClientService
}
// NewClientHWIDController creates a new ClientHWIDController.
func NewClientHWIDController(g *gin.RouterGroup) *ClientHWIDController {
a := &ClientHWIDController{
clientHWIDService: &service.ClientHWIDService{},
clientService: &service.ClientService{},
}
a.initRouter(g)
return a
}
// initRouter sets up routes for client HWID management.
func (a *ClientHWIDController) initRouter(g *gin.RouterGroup) {
g = g.Group("/hwid")
{
g.GET("/list/:clientId", a.getHWIDs)
g.POST("/add", a.addHWID)
g.POST("/del/:id", a.removeHWID) // Changed to /del/:id to match API style
g.POST("/deactivate/:id", a.deactivateHWID)
g.POST("/check", a.checkHWID)
g.POST("/register", a.registerHWID)
}
}
// getHWIDs retrieves all HWIDs for a specific client.
func (a *ClientHWIDController) getHWIDs(c *gin.Context) {
clientIdStr := c.Param("clientId")
clientId, err := strconv.Atoi(clientIdStr)
if err != nil {
jsonMsg(c, "Invalid client ID", nil)
return
}
hwids, err := a.clientHWIDService.GetHWIDsForClient(clientId)
if err != nil {
jsonMsg(c, "Failed to get HWIDs", err)
return
}
jsonObj(c, hwids, nil)
}
// addHWID adds a new HWID for a client (manual addition by admin).
func (a *ClientHWIDController) addHWID(c *gin.Context) {
var req struct {
ClientId int `json:"clientId" form:"clientId" binding:"required"`
HWID string `json:"hwid" form:"hwid" binding:"required"`
DeviceOS string `json:"deviceOs" form:"deviceOs"`
DeviceModel string `json:"deviceModel" form:"deviceModel"`
OSVersion string `json:"osVersion" form:"osVersion"`
IPAddress string `json:"ipAddress" form:"ipAddress"`
UserAgent string `json:"userAgent" form:"userAgent"`
}
if err := c.ShouldBind(&req); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
hwid, err := a.clientHWIDService.AddHWIDForClient(req.ClientId, req.HWID, req.DeviceOS, req.DeviceModel, req.OSVersion, req.IPAddress, req.UserAgent)
if err != nil {
jsonMsg(c, "Failed to add HWID", err)
return
}
jsonObj(c, hwid, nil)
}
// removeHWID removes a HWID from a client.
func (a *ClientHWIDController) removeHWID(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
jsonMsg(c, "Invalid HWID ID", nil)
return
}
err = a.clientHWIDService.RemoveHWID(id)
if err != nil {
jsonMsg(c, "Failed to remove HWID", err)
return
}
jsonMsg(c, "HWID removed successfully", nil)
}
// deactivateHWID deactivates a HWID (marks as inactive).
func (a *ClientHWIDController) deactivateHWID(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
jsonMsg(c, "Invalid HWID ID", nil)
return
}
err = a.clientHWIDService.DeactivateHWID(id)
if err != nil {
jsonMsg(c, "Failed to deactivate HWID", err)
return
}
jsonMsg(c, "HWID deactivated successfully", nil)
}
// checkHWID checks if a HWID is allowed for a client.
func (a *ClientHWIDController) checkHWID(c *gin.Context) {
var req struct {
ClientId int `json:"clientId" form:"clientId" binding:"required"`
HWID string `json:"hwid" form:"hwid" binding:"required"`
}
if err := c.ShouldBind(&req); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
allowed, err := a.clientHWIDService.CheckHWIDAllowed(req.ClientId, req.HWID)
if err != nil {
jsonMsg(c, "Failed to check HWID", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"obj": gin.H{
"allowed": allowed,
},
})
}
// registerHWID registers a HWID for a client (called by client applications).
// This endpoint reads HWID and device metadata from HTTP headers:
// - x-hwid (required): Hardware ID
// - x-device-os (optional): Device operating system
// - x-device-model (optional): Device model
// - x-ver-os (optional): OS version
// - user-agent (optional): User agent string
func (a *ClientHWIDController) registerHWID(c *gin.Context) {
var req struct {
Email string `json:"email" form:"email" binding:"required"`
}
if err := c.ShouldBind(&req); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
// Read HWID from headers (primary method)
hwid := c.GetHeader("x-hwid")
if hwid == "" {
// Try alternative header name (case-insensitive)
hwid = c.GetHeader("X-HWID")
}
if hwid == "" {
jsonMsg(c, "HWID is required (x-hwid header missing)", nil)
return
}
// Read device metadata from headers
deviceOS := c.GetHeader("x-device-os")
if deviceOS == "" {
deviceOS = c.GetHeader("X-Device-OS")
}
deviceModel := c.GetHeader("x-device-model")
if deviceModel == "" {
deviceModel = c.GetHeader("X-Device-Model")
}
osVersion := c.GetHeader("x-ver-os")
if osVersion == "" {
osVersion = c.GetHeader("X-Ver-OS")
}
userAgent := c.GetHeader("User-Agent")
ipAddress := c.ClientIP()
// Get client by email
client, err := a.clientService.GetClientByEmail(1, req.Email) // TODO: Get userId from session
if err != nil {
jsonMsg(c, "Client not found", err)
return
}
// Register HWID using RegisterHWIDFromHeaders
hwidRecord, err := a.clientHWIDService.RegisterHWIDFromHeaders(client.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent)
if err != nil {
// Check if error is HWID limit exceeded
if strings.Contains(err.Error(), "HWID limit exceeded") {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"msg": err.Error(),
})
return
}
jsonMsg(c, "Failed to register HWID", err)
return
}
if hwidRecord == nil {
// HWID tracking disabled (hwidMode = "off")
c.JSON(http.StatusOK, gin.H{
"success": true,
"msg": "HWID tracking is disabled",
})
return
}
jsonObj(c, hwidRecord, nil)
}

253
web/controller/host.go Normal file
View file

@ -0,0 +1,253 @@
// Package controller provides HTTP handlers for host management in multi-node mode.
package controller
import (
"bytes"
"encoding/json"
"io"
"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/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
// HostController handles HTTP requests related to host management.
type HostController struct {
hostService service.HostService
}
// NewHostController creates a new HostController and sets up its routes.
func NewHostController(g *gin.RouterGroup) *HostController {
a := &HostController{
hostService: service.HostService{},
}
a.initRouter(g)
return a
}
// initRouter initializes the routes for host-related operations.
func (a *HostController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getHosts)
g.GET("/get/:id", a.getHost)
g.POST("/add", a.addHost)
g.POST("/update/:id", a.updateHost)
g.POST("/del/:id", a.deleteHost)
}
// getHosts retrieves the list of all hosts for the current user.
func (a *HostController) getHosts(c *gin.Context) {
user := session.GetLoginUser(c)
hosts, err := a.hostService.GetHosts(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, hosts, nil)
}
// getHost retrieves a specific host by its ID.
func (a *HostController) getHost(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid host ID", err)
return
}
user := session.GetLoginUser(c)
host, err := a.hostService.GetHost(id)
if err != nil {
jsonMsg(c, "Failed to get host", err)
return
}
if host.UserId != user.Id {
jsonMsg(c, "Host not found or access denied", nil)
return
}
jsonObj(c, host, nil)
}
// addHost creates a new host.
func (a *HostController) addHost(c *gin.Context) {
user := session.GetLoginUser(c)
// Extract inboundIds from JSON or form data
var inboundIdsFromJSON []int
var hasInboundIdsInJSON bool
if c.ContentType() == "application/json" {
// Read raw body to extract inboundIds
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
// Parse JSON to extract inboundIds
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
// Check for inboundIds array
if inboundIdsVal, ok := jsonData["inboundIds"]; ok {
hasInboundIdsInJSON = true
if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok {
for _, val := range inboundIdsArray {
if num, ok := val.(float64); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := val.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
} else if num, ok := inboundIdsVal.(float64); ok {
// Single number instead of array
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := inboundIdsVal.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
}
// Restore body for ShouldBind
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
host := &model.Host{}
err := c.ShouldBind(host)
if err != nil {
jsonMsg(c, "Invalid host data", err)
return
}
// Set inboundIds from JSON if available
if hasInboundIdsInJSON {
host.InboundIds = inboundIdsFromJSON
logger.Debugf("AddHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON)
} else {
// Try to get from form data
inboundIdsStr := c.PostFormArray("inboundIds")
if len(inboundIdsStr) > 0 {
var inboundIds []int
for _, idStr := range inboundIdsStr {
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
inboundIds = append(inboundIds, id)
}
}
}
host.InboundIds = inboundIds
logger.Debugf("AddHost: extracted inboundIds from form: %v", inboundIds)
}
}
logger.Debugf("AddHost: host.InboundIds before service call: %v", host.InboundIds)
err = a.hostService.AddHost(user.Id, host)
if err != nil {
logger.Errorf("Failed to add host: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostCreateSuccess"), host, nil)
}
// updateHost updates an existing host.
func (a *HostController) updateHost(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid host ID", err)
return
}
user := session.GetLoginUser(c)
// Extract inboundIds from JSON or form data
var inboundIdsFromJSON []int
var hasInboundIdsInJSON bool
if c.ContentType() == "application/json" {
// Read raw body to extract inboundIds
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
// Parse JSON to extract inboundIds
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
// Check for inboundIds array
if inboundIdsVal, ok := jsonData["inboundIds"]; ok {
hasInboundIdsInJSON = true
if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok {
for _, val := range inboundIdsArray {
if num, ok := val.(float64); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := val.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
} else if num, ok := inboundIdsVal.(float64); ok {
// Single number instead of array
inboundIdsFromJSON = append(inboundIdsFromJSON, int(num))
} else if num, ok := inboundIdsVal.(int); ok {
inboundIdsFromJSON = append(inboundIdsFromJSON, num)
}
}
}
// Restore body for ShouldBind
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
host := &model.Host{}
err = c.ShouldBind(host)
if err != nil {
jsonMsg(c, "Invalid host data", err)
return
}
// Set inboundIds from JSON if available
if hasInboundIdsInJSON {
host.InboundIds = inboundIdsFromJSON
logger.Debugf("UpdateHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON)
} else {
// Try to get from form data
inboundIdsStr := c.PostFormArray("inboundIds")
if len(inboundIdsStr) > 0 {
var inboundIds []int
for _, idStr := range inboundIdsStr {
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
inboundIds = append(inboundIds, id)
}
}
}
host.InboundIds = inboundIds
logger.Debugf("UpdateHost: extracted inboundIds from form: %v", inboundIds)
} else {
logger.Debugf("UpdateHost: inboundIds not provided, keeping existing assignments")
}
}
host.Id = id
err = a.hostService.UpdateHost(user.Id, host)
if err != nil {
logger.Errorf("Failed to update host: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostUpdateSuccess"), host, nil)
}
// deleteHost deletes a host by ID.
func (a *HostController) deleteHost(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid host ID", err)
return
}
user := session.GetLoginUser(c)
err = a.hostService.DeleteHost(user.Id, id)
if err != nil {
logger.Errorf("Failed to delete host: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.hostDeleteSuccess"), nil)
}

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.

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

@ -0,0 +1,561 @@
// Package controller provides HTTP handlers for node management in multi-node mode.
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
"github.com/gin-gonic/gin"
)
// 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)
g.POST("/logs/:id", a.getNodeLogs)
g.POST("/check-connection", a.checkNodeConnection) // Check node connection without API key
// push-logs endpoint moved to APIController to bypass session auth
}
// getNodes retrieves the list of all nodes.
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 and registers it with a generated API key.
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("[Node: %s] Adding node: address=%s", node.Name, node.Address)
// Note: Connection check is done on frontend via /panel/node/check-connection endpoint
// to avoid CORS issues. Here we proceed directly to registration.
// Generate API key and register node
apiKey, err := a.nodeService.RegisterNode(node)
if err != nil {
logger.Errorf("[Node: %s] Registration failed: %v", node.Name, err)
jsonMsg(c, "Failed to register node: "+err.Error(), err)
return
}
// Set the generated API key
node.ApiKey = apiKey
// Set default status
if node.Status == "" {
node.Status = "unknown"
}
// Save node to database
err = a.nodeService.AddNode(node)
if err != nil {
jsonMsg(c, "Failed to add node to database", err)
return
}
// Check health immediately
go a.nodeService.CheckNodeHealth(node)
// Broadcast nodes update via WebSocket
a.broadcastNodesUpdate()
logger.Infof("[Node: %s] Node added and registered successfully", node.Name)
jsonMsgObj(c, "Node added and registered successfully", node, nil)
}
// updateNode updates an existing node.
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
}
// TLS settings
if useTlsVal, ok := jsonData["useTls"].(bool); ok {
node.UseTLS = useTlsVal
}
if certPathVal, ok := jsonData["certPath"].(string); ok {
node.CertPath = certPathVal
}
if keyPathVal, ok := jsonData["keyPath"].(string); ok {
node.KeyPath = keyPathVal
}
if insecureTlsVal, ok := jsonData["insecureTls"].(bool); ok {
node.InsecureTLS = insecureTlsVal
}
}
} 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
}
// TLS settings
node.UseTLS = c.PostForm("useTls") == "true" || c.PostForm("useTls") == "on"
if certPath := c.PostForm("certPath"); certPath != "" {
node.CertPath = certPath
}
if keyPath := c.PostForm("keyPath"); keyPath != "" {
node.KeyPath = keyPath
}
node.InsecureTLS = c.PostForm("insecureTls") == "true" || c.PostForm("insecureTls") == "on"
}
// 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
}
// Broadcast nodes update via WebSocket
a.broadcastNodesUpdate()
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
}
// Broadcast nodes update via WebSocket
a.broadcastNodesUpdate()
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
}
// Broadcast nodes update via WebSocket (to update status and response time)
a.broadcastNodesUpdate()
jsonMsgObj(c, "Node health check completed", node, nil)
}
// checkAllNodes checks the health of all nodes.
func (a *NodeController) checkAllNodes(c *gin.Context) {
a.nodeService.CheckAllNodesHealth()
// Broadcast nodes update after health check (with delay to allow all checks to complete)
go func() {
time.Sleep(3 * time.Second) // Wait for health checks to complete
a.broadcastNodesUpdate()
}()
jsonMsg(c, "Health check initiated for all nodes", nil)
}
// 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)
}
// getNodeLogs retrieves XRAY logs from a specific node.
func (a *NodeController) getNodeLogs(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid node ID", err)
return
}
node, err := a.nodeService.GetNode(id)
if err != nil {
jsonMsg(c, "Failed to get node", err)
return
}
count := c.DefaultPostForm("count", "100")
filter := c.PostForm("filter")
showDirect := c.DefaultPostForm("showDirect", "true")
showBlocked := c.DefaultPostForm("showBlocked", "true")
showProxy := c.DefaultPostForm("showProxy", "true")
countInt, _ := strconv.Atoi(count)
// Get raw logs from node
rawLogs, err := a.nodeService.GetNodeLogs(node, countInt, filter)
if err != nil {
jsonMsg(c, "Failed to get logs from node", err)
return
}
// Parse logs into LogEntry format (similar to ServerService.GetXrayLogs)
type LogEntry struct {
DateTime time.Time `json:"DateTime"`
FromAddress string `json:"FromAddress"`
ToAddress string `json:"ToAddress"`
Inbound string `json:"Inbound"`
Outbound string `json:"Outbound"`
Email string `json:"Email"`
Event int `json:"Event"`
}
const (
Direct = iota
Blocked
Proxied
)
var freedoms []string
var blackholes []string
// Get tags for freedom and blackhole outbounds from default config
settingService := service.SettingService{}
config, err := settingService.GetDefaultXrayConfig()
if err == nil && config != nil {
if cfgMap, ok := config.(map[string]any); ok {
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
for _, outbound := range outbounds {
if obMap, ok := outbound.(map[string]any); ok {
switch obMap["protocol"] {
case "freedom":
if tag, ok := obMap["tag"].(string); ok {
freedoms = append(freedoms, tag)
}
case "blackhole":
if tag, ok := obMap["tag"].(string); ok {
blackholes = append(blackholes, tag)
}
}
}
}
}
}
}
if len(freedoms) == 0 {
freedoms = []string{"direct"}
}
if len(blackholes) == 0 {
blackholes = []string{"blocked"}
}
var entries []LogEntry
for _, line := range rawLogs {
var entry LogEntry
parts := strings.Fields(line)
for i, part := range parts {
if i == 0 && len(parts) > 1 {
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
if err == nil {
entry.DateTime = dateTime.UTC()
}
}
if part == "from" && i+1 < len(parts) {
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
} else if part == "accepted" && i+1 < len(parts) {
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
} else if strings.HasPrefix(part, "[") {
entry.Inbound = part[1:]
} else if strings.HasSuffix(part, "]") {
entry.Outbound = part[:len(part)-1]
} else if part == "email:" && i+1 < len(parts) {
entry.Email = parts[i+1]
}
}
// Determine event type
logEntryContains := func(line string, suffixes []string) bool {
for _, sfx := range suffixes {
if strings.Contains(line, sfx+"]") {
return true
}
}
return false
}
if logEntryContains(line, freedoms) {
if showDirect == "false" {
continue
}
entry.Event = Direct
} else if logEntryContains(line, blackholes) {
if showBlocked == "false" {
continue
}
entry.Event = Blocked
} else {
if showProxy == "false" {
continue
}
entry.Event = Proxied
}
entries = append(entries, entry)
}
jsonObj(c, entries, nil)
}
// checkNodeConnection checks if a node is reachable (health check without API key).
// This is used during node registration to verify connectivity before registration.
func (a *NodeController) checkNodeConnection(c *gin.Context) {
type CheckConnectionRequest struct {
Address string `json:"address" form:"address" binding:"required"`
}
var req CheckConnectionRequest
// HttpUtil.post sends data as form-urlencoded (see axios-init.js)
// So we use ShouldBind which handles both form and JSON
if err := c.ShouldBind(&req); err != nil {
jsonMsg(c, "Invalid request: "+err.Error(), err)
return
}
if req.Address == "" {
jsonMsg(c, "Address is required", nil)
return
}
// Create a temporary node object for health check
tempNode := &model.Node{
Address: req.Address,
}
// Check node health (this only uses /health endpoint, no API key required)
status, responseTime, err := a.nodeService.CheckNodeStatus(tempNode)
if err != nil {
jsonMsg(c, "Node is not reachable: "+err.Error(), err)
return
}
if status != "online" {
jsonMsg(c, "Node is not online (status: "+status+")", nil)
return
}
// Return response time along with success message
jsonMsgObj(c, fmt.Sprintf("Node is reachable (response time: %d ms)", responseTime), map[string]interface{}{
"responseTime": responseTime,
}, nil)
}
// broadcastNodesUpdate broadcasts the current nodes list to all WebSocket clients
func (a *NodeController) broadcastNodesUpdate() {
// Get all nodes with their inbounds
nodes, err := a.nodeService.GetAllNodes()
if err != nil {
logger.Warningf("Failed to get nodes for WebSocket broadcast: %v", err)
return
}
// Enrich nodes with assigned inbounds information
type NodeWithInbounds struct {
*model.Node
Inbounds []*model.Inbound `json:"inbounds,omitempty"`
}
result := make([]NodeWithInbounds, 0, len(nodes))
for _, node := range nodes {
inbounds, _ := a.nodeService.GetInboundsForNode(node.Id)
result = append(result, NodeWithInbounds{
Node: node,
Inbounds: inbounds,
})
}
// Broadcast via WebSocket
websocket.BroadcastNodes(result)
}

View file

@ -237,7 +237,8 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
blackholes = []string{"blocked"} blackholes = []string{"blocked"}
} }
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes) nodeId := c.PostForm("nodeId")
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes, nodeId)
jsonObj(c, logs, nil) jsonObj(c, logs, nil)
} }

View file

@ -8,6 +8,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -92,6 +93,16 @@ func getContext(h gin.H) gin.H {
a := gin.H{ a := gin.H{
"cur_ver": config.GetVersion(), "cur_ver": config.GetVersion(),
} }
// Add multiNodeMode to context for all pages
settingService := service.SettingService{}
multiNodeMode, err := settingService.GetMultiNodeMode()
if err != nil {
// If error, default to false (single mode)
multiNodeMode = false
}
a["multiNodeMode"] = multiNodeMode
for key, value := range h { for key, value := range h {
a[key] = value a[key] = value
} }

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,18 @@ 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)
g.GET("/clients", a.clients)
g.GET("/hosts", a.hosts)
a.settingController = NewSettingController(g) a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g) a.xraySettingController = NewXraySettingController(g)
a.nodeController = NewNodeController(g.Group("/node"))
// Register client and host controllers directly under /panel (not /panel/api)
NewClientController(g.Group("/client"))
NewHostController(g.Group("/host"))
NewClientHWIDController(g.Group("/client")) // Register HWID controller under /panel/client/hwid
} }
// index renders the main panel index page. // index renders the main panel index page.
@ -52,3 +62,18 @@ 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)
}
// clients renders the clients management page.
func (a *XUIController) clients(c *gin.Context) {
html(c, "clients.html", "pages.clients.title", nil)
}
// hosts renders the hosts management page (multi-node mode).
func (a *XUIController) hosts(c *gin.Context) {
html(c, "hosts.html", "pages.hosts.title", nil)
}

View file

@ -98,6 +98,15 @@ 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
// HWID tracking mode
// "off" = HWID tracking disabled
// "client_header" = HWID provided by client via x-hwid header (default, recommended)
// "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only)
HwidMode string `json:"hwidMode" form:"hwidMode"` // HWID tracking mode
// JSON subscription routing rules // JSON subscription routing rules
} }
@ -168,5 +177,15 @@ func (s *AllSetting) CheckValid() error {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)
} }
// Validate HWID mode
validHwidModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if s.HwidMode != "" && !validHwidModes[s.HwidMode] {
return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", s.HwidMode)
}
return nil return nil
} }

942
web/html/clients.html Normal file
View file

@ -0,0 +1,942 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' clients-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content :style="{ padding: '24px 16px' }">
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<transition name="list" appear>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
<a-col>
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<a-button type="primary" icon="plus" @click="openAddClient">
<template v-if="!isMobile">{{ i18n "pages.clients.addClient" }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary" icon="menu">
<template v-if="!isMobile">{{ i18n "pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</a-space>
</template>
<template #extra>
<a-button-group>
<a-button icon="sync" @click="manualRefresh" :loading="refreshing"></a-button>
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
<span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
<a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
@change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select>
</a-space>
</template>
<a-button icon="down"></a-button>
</a-popover>
</a-button-group>
</template>
<a-space direction="vertical">
<div :style="isMobile ? {} : { display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }">
<a-switch v-model="enableFilter"
:style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
@change="toggleFilter">
<a-icon slot="checkedChildren" type="search"></a-icon>
<a-icon slot="unCheckedChildren" type="filter"></a-icon>
</a-switch>
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
:style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterClients" button-style="solid"
:size="isMobile ? 'small' : ''">
<a-radio-button value="">{{ i18n "none" }}</a-radio-button>
<a-radio-button value="deactive">{{ i18n "disabled" }}</a-radio-button>
<a-radio-button value="depleted">{{ i18n "depleted" }}</a-radio-button>
<a-radio-button value="expiring">{{ i18n "depletingSoon" }}</a-radio-button>
<a-radio-button value="online">{{ i18n "online" }}</a-radio-button>
</a-radio-group>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="client => client.id"
:data-source="searchedClients" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="clients-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, client">
<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, client)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="qrcode" v-if="client.inbounds && client.inbounds.length > 0">
<a-icon type="qrcode"></a-icon>
{{ i18n "qrCode" }}
</a-menu-item>
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetTraffic" }}
</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="email" slot-scope="text, client">
<span>[[ client.email || '-' ]]</span>
</template>
<template slot="inbounds" slot-scope="text, client">
<template v-if="client.inbounds && client.inbounds.length > 0">
<a-tag v-for="(inbound, index) in client.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="enable" slot-scope="text, client">
<a-switch v-model="client.enable" @change="switchEnable(client.id, client.enable)"></a-switch>
</template>
<template slot="status" slot-scope="text, client">
<a-tag v-if="isClientOnline(client.email)" color="green">{{ i18n "online" }}</a-tag>
<a-tag v-else color="default">{{ i18n "offline" }}</a-tag>
</template>
<template slot="traffic" slot-scope="text, client">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>↓[[ SizeFormatter.sizeFormat(client.up || 0) ]]</td>
<td>↑[[ SizeFormatter.sizeFormat(client.down || 0) ]]</td>
</tr>
<tr v-if="getClientTotal(client) > 0 && (client.up || 0) + (client.down || 0) < getClientTotal(client)">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(getClientTotal(client) - (client.up || 0) - (client.down || 0)) ]]</td>
</tr>
</table>
</template>
<a-tag :color="ColorUtils.usageColor((client.up || 0) + (client.down || 0), 0, getClientTotal(client))">
[[ SizeFormatter.sizeFormat((client.up || 0) + (client.down || 0)) ]] /
<template v-if="getClientTotal(client) > 0">
[[ SizeFormatter.sizeFormat(getClientTotal(client)) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</template>
</a-tag>
</a-popover>
</template>
<template slot="expiryTime" slot-scope="text, client">
<a-popover v-if="client.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
[[ IntlUtil.formatDate(client.expiryTime) ]]
</template>
<a-tag :style="{ minWidth: '50px' }"
:color="ColorUtils.usageColor(new Date().getTime(), 0, client.expiryTime)">
[[ IntlUtil.formatRelativeTime(client.expiryTime) ]]
</a-tag>
</a-popover>
<a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</a-tag>
</template>
</a-table>
</a-space>
</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>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "modals/qrcodeModal"}}
{{template "modals/clientEntityModal"}}
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 50,
}, {
title: '{{ i18n "pages.clients.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.clients.email" }}',
align: 'left',
width: 200,
scopedSlots: { customRender: 'email' },
}, {
title: '{{ i18n "pages.clients.inbounds" }}',
align: 'left',
width: 250,
scopedSlots: { customRender: 'inbounds' },
}, {
title: '{{ i18n "status" }}',
align: 'center',
width: 100,
scopedSlots: { customRender: 'status' },
}, {
title: '{{ i18n "pages.clients.traffic" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.clients.expiryTime" }}',
align: 'left',
width: 120,
scopedSlots: { customRender: 'expiryTime' },
}, {
title: '{{ i18n "pages.clients.enable" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'enable' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
}, {
title: '{{ i18n "pages.clients.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.clients.email" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'email' },
}, {
title: '{{ i18n "pages.clients.enable" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'enable' },
}];
const app = window.app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
clients: [],
searchedClients: [],
allInbounds: [],
availableNodes: [],
refreshing: false,
onlineClients: [],
lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
searchKey: '',
enableFilter: false,
filterBy: '',
expireDiff: 0,
trafficDiff: 0,
subSettings: {
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
},
remarkModel: '-ieo',
},
methods: {
loading(spinning = true) {
this.loadingStates.spinning = spinning;
},
async loadClients() {
this.refreshing = true;
try {
// Load online clients and last online map first
await this.getOnlineUsers();
await this.getLastOnlineMap();
const msg = await HttpUtil.get('/panel/client/list');
if (msg && msg.success && msg.obj) {
this.clients = msg.obj;
// Load inbounds for each client
await this.loadInboundsForClients();
// Apply current filter/search
if (this.enableFilter) {
this.filterClients();
} else {
this.searchClients(this.searchKey);
}
// Ensure searchedClients is initialized
if (this.searchedClients.length === 0 && this.clients.length > 0) {
this.searchedClients = this.clients.slice();
}
}
} catch (e) {
console.error("Failed to load clients:", e);
app.$message.error('{{ i18n "pages.clients.loadError" }}');
} finally {
this.refreshing = false;
this.loadingStates.fetched = true;
}
},
async getOnlineUsers() {
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
if (!msg.success) {
return;
}
this.onlineClients = msg.obj != null ? msg.obj : [];
},
async getLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (!msg.success || !msg.obj) return;
this.lastOnlineMap = msg.obj || {}
},
isClientOnline(email) {
return this.onlineClients.includes(email);
},
getLastOnline(email) {
return this.lastOnlineMap[email] || null
},
formatLastOnline(email) {
const ts = this.getLastOnline(email)
if (!ts) return '-'
// Check if IntlUtil is available (may not be loaded yet)
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
return IntlUtil.formatDate(ts)
}
// Fallback to simple date formatting if IntlUtil is not available
return new Date(ts).toLocaleString()
},
getClientTotal(client) {
// Convert TotalGB to bytes (1 GB = 1024^3 bytes)
// TotalGB can now be a decimal value (e.g., 0.01 for MB)
if (client.totalGB && client.totalGB > 0) {
return client.totalGB * 1024 * 1024 * 1024;
}
return 0;
},
async loadInboundsForClients() {
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
this.allInbounds = inboundsMsg.obj;
// Map inbound IDs to full inbound objects for each client
this.clients.forEach(client => {
if (client.inboundIds && Array.isArray(client.inboundIds)) {
client.inbounds = client.inboundIds.map(id => {
return this.allInbounds.find(ib => ib.id === id);
}).filter(ib => ib != null);
} else {
client.inbounds = [];
}
});
}
} catch (e) {
console.error("Failed to load inbounds for clients:", e);
}
},
async getDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg.success) {
return;
}
with (msg.obj) {
this.expireDiff = expireDiff * 86400000;
this.trafficDiff = trafficDiff * 1073741824;
this.subSettings = {
enable: subEnable,
subTitle: subTitle,
subURI: subURI,
subJsonURI: subJsonURI,
subJsonEnable: subJsonEnable,
};
this.remarkModel = remarkModel;
}
},
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.error("Failed to load available nodes:", e);
}
},
clickAction(action, client) {
switch (action.key) {
case 'qrcode':
this.showQrcode(client);
break;
case 'edit':
this.editClient(client);
break;
case 'resetTraffic':
this.resetClientTraffic(client);
break;
case 'delete':
this.deleteClient(client.id);
break;
}
},
showQrcode(client) {
// Show QR codes for all inbounds assigned to this client
if (!client.inbounds || client.inbounds.length === 0) {
app.$message.warning('{{ i18n "tgbot.noInbounds" }}');
return;
}
// Convert ClientEntity to client format for qrModal
const clientForQR = {
email: client.email,
id: client.uuid || client.email,
password: client.password || '',
security: client.security || 'auto',
flow: client.flow || '',
subId: client.subId || '' // Add subId for subscription link generation
};
// Collect QR codes from all inbounds
const allQRCodes = [];
// Process each inbound assigned to this client
client.inbounds.forEach(inbound => {
if (!inbound) return;
// Load full inbound data to create DBInbound
const dbInbound = this.allInbounds.find(ib => ib.id === inbound.id);
if (!dbInbound) return;
// Create a DBInbound object from the inbound data
const dbInboundObj = new DBInbound(dbInbound);
const inboundObj = dbInboundObj.toInbound();
// Generate links for this inbound
// Get inbound remark (fallback to ID if remark is empty)
const inboundRemarkForWireguard = (dbInbound.remark && dbInbound.remark.trim()) || ('Inbound #' + dbInbound.id);
if (inboundObj.protocol == Protocols.WIREGUARD) {
inboundObj.genInboundLinks(dbInbound.remark).split('\r\n').forEach((l, index) => {
allQRCodes.push({
remark: inboundRemarkForWireguard + " - Peer " + (index + 1),
link: l,
useIPv4: false,
originalLink: l
});
});
} else {
const links = inboundObj.genAllLinks(dbInbound.remark, this.remarkModel, clientForQR);
const hasMultipleNodes = links.length > 1 && links.some(l => l.nodeId !== null);
const hasMultipleInbounds = client.inbounds.length > 1;
// Get inbound remark (fallback to ID if remark is empty)
const inboundRemark = (dbInbound.remark && dbInbound.remark.trim()) || ('Inbound #' + dbInbound.id);
links.forEach(l => {
// Build display remark - always start with inbound name
let displayRemark = inboundRemark;
// If multiple nodes, append node name
if (hasMultipleNodes && l.nodeId !== null) {
const node = this.availableNodes && this.availableNodes.find(n => n.id === l.nodeId);
if (node && node.name) {
displayRemark = inboundRemark + " - " + node.name;
}
}
// Ensure remark is never empty
if (!displayRemark || !displayRemark.trim()) {
displayRemark = 'Inbound #' + dbInbound.id;
}
allQRCodes.push({
remark: displayRemark,
link: l.link,
useIPv4: false,
originalLink: l.link,
nodeId: l.nodeId
});
});
}
});
// If we have QR codes, show them in the modal
if (allQRCodes.length > 0) {
// Set up qrModal with first inbound (for subscription links if enabled)
const firstDbInbound = this.allInbounds.find(ib => ib.id === client.inbounds[0].id);
if (firstDbInbound) {
const firstDbInboundObj = new DBInbound(firstDbInbound);
// Set modal properties
qrModal.title = '{{ i18n "qrCode"}} - ' + client.email;
qrModal.dbInbound = firstDbInboundObj;
qrModal.inbound = firstDbInboundObj.toInbound();
qrModal.client = clientForQR;
qrModal.subId = clientForQR.subId || '';
// Clear and set qrcodes array - use Vue.set for reactivity if needed
qrModal.qrcodes.length = 0;
allQRCodes.forEach(qr => {
// Ensure remark is set and not empty
if (!qr.remark || !qr.remark.trim()) {
qr.remark = 'QR Code';
}
qrModal.qrcodes.push(qr);
});
// Show modal
qrModal.visible = true;
// Reset the status fetched flag
if (qrModalApp) {
qrModalApp.statusFetched = false;
}
}
} else {
app.$message.warning('{{ i18n "tgbot.noInbounds" }}');
}
},
openAddClient() {
// Call directly like inModal.show() in inbounds.html
if (typeof window.clientEntityModal !== 'undefined') {
window.clientEntityModal.show({
title: '{{ i18n "pages.clients.addClient" }}',
okText: '{{ i18n "create" }}',
confirm: async (client) => {
await this.submitClient(client, false);
},
isEdit: false
});
} else if (typeof clientEntityModal !== 'undefined') {
clientEntityModal.show({
title: '{{ i18n "pages.clients.addClient" }}',
okText: '{{ i18n "create" }}',
confirm: async (client) => {
await this.submitClient(client, false);
},
isEdit: false
});
} else {
console.error('[openAddClient] ERROR: clientEntityModal is not defined!');
}
},
async editClient(client) {
// Load full client data including HWIDs
try {
const msg = await HttpUtil.get(`/panel/client/get/${client.id}`);
if (msg && msg.success && msg.obj) {
client = msg.obj; // Use full client data from API
}
} catch (e) {
console.error("Failed to load full client data:", e);
}
// Call directly like inModal.show() in inbounds.html
if (typeof window.clientEntityModal !== 'undefined') {
window.clientEntityModal.show({
title: '{{ i18n "pages.clients.editClient" }}',
okText: '{{ i18n "update" }}',
client: client,
confirm: async (client) => {
await this.submitClient(client, true);
},
isEdit: true
});
} else if (typeof clientEntityModal !== 'undefined') {
clientEntityModal.show({
title: '{{ i18n "pages.clients.editClient" }}',
okText: '{{ i18n "update" }}',
client: client,
confirm: async (client) => {
await this.submitClient(client, true);
},
isEdit: true
});
}
},
async submitClient(client, isEdit) {
if (!client.email || !client.email.trim()) {
app.$message.error('{{ i18n "pages.clients.emailRequired" }}');
return;
}
clientEntityModal.loading(true);
try {
// Convert date picker value to timestamp
if (client._expiryTime) {
if (moment && moment.isMoment(client._expiryTime)) {
client.expiryTime = client._expiryTime.valueOf();
} else if (client._expiryTime instanceof Date) {
client.expiryTime = client._expiryTime.getTime();
} else if (typeof client._expiryTime === 'number') {
client.expiryTime = client._expiryTime;
} else {
client.expiryTime = parseInt(client._expiryTime) || 0;
}
} else {
client.expiryTime = 0;
}
let msg;
if (isEdit) {
msg = await HttpUtil.post(`/panel/client/update/${client.id}`, client);
} else {
msg = await HttpUtil.post('/panel/client/add', client);
}
if (msg.success) {
app.$message.success(isEdit ? '{{ i18n "pages.clients.updateSuccess" }}' : '{{ i18n "pages.clients.addSuccess" }}');
clientEntityModal.close();
await this.loadClients();
} else {
app.$message.error(msg.msg || (isEdit ? '{{ i18n "pages.clients.updateError" }}' : '{{ i18n "pages.clients.addError" }}'));
}
} catch (e) {
console.error("Failed to submit client:", e);
app.$message.error(isEdit ? '{{ i18n "pages.clients.updateError" }}' : '{{ i18n "pages.clients.addError" }}');
} finally {
clientEntityModal.loading(false);
}
},
async deleteClient(id) {
this.$confirm({
title: '{{ i18n "pages.clients.deleteConfirm" }}',
content: '{{ i18n "pages.clients.deleteConfirmText" }}',
okText: '{{ i18n "sure" }}',
okType: 'danger',
cancelText: '{{ i18n "close" }}',
onOk: async () => {
try {
const msg = await HttpUtil.post(`/panel/client/del/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.clients.deleteSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.clients.deleteError" }}');
}
} catch (e) {
console.error("Failed to delete client:", e);
app.$message.error('{{ i18n "pages.clients.deleteError" }}');
}
}
});
},
async switchEnable(id, enable) {
try {
const msg = await HttpUtil.post(`/panel/client/update/${id}`, { enable: enable });
if (msg.success) {
app.$message.success('{{ i18n "pages.clients.updateSuccess" }}');
} else {
app.$message.error(msg.msg || '{{ i18n "pages.clients.updateError" }}');
// Revert switch
const client = this.clients.find(c => c.id === id);
if (client) {
client.enable = !enable;
}
}
} catch (e) {
console.error("Failed to update client:", e);
app.$message.error('{{ i18n "pages.clients.updateError" }}');
// Revert switch
const client = this.clients.find(c => c.id === id);
if (client) {
client.enable = !enable;
}
}
},
async startDataRefreshLoop() {
while (this.isRefreshEnabled) {
try {
await this.loadClients();
} catch (e) {
console.error(e);
}
await PromiseUtil.sleep(this.refreshInterval);
}
},
toggleRefresh() {
localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
},
changeRefreshInterval() {
localStorage.setItem("refreshInterval", this.refreshInterval);
},
async manualRefresh() {
if (!this.refreshing) {
this.loadingStates.spinning = true;
await this.loadClients();
this.loadingStates.spinning = false;
}
},
searchClients(key) {
if (ObjectUtil.isEmpty(key)) {
this.searchedClients = this.clients.slice();
} else {
this.searchedClients.splice(0, this.searchedClients.length);
this.clients.forEach(client => {
if (ObjectUtil.deepSearch(client, key)) {
this.searchedClients.push(client);
}
});
}
},
filterClients() {
if (ObjectUtil.isEmpty(this.filterBy)) {
this.searchedClients = this.clients.slice();
} else {
this.searchedClients.splice(0, this.searchedClients.length);
const now = new Date().getTime();
this.clients.forEach(client => {
let shouldInclude = false;
switch (this.filterBy) {
case 'deactive':
shouldInclude = !client.enable;
break;
case 'depleted':
const exhausted = client.totalGB > 0 && (client.up || 0) + (client.down || 0) >= client.totalGB * 1024 * 1024 * 1024;
const expired = client.expiryTime > 0 && client.expiryTime <= now;
shouldInclude = expired || exhausted;
break;
case 'expiring':
const expiringSoon = (client.expiryTime > 0 && (client.expiryTime - now < this.expireDiff)) ||
(client.totalGB > 0 && (client.totalGB * 1024 * 1024 * 1024 - (client.up || 0) - (client.down || 0) < this.trafficDiff));
shouldInclude = expiringSoon && !this.isClientDepleted(client);
break;
case 'online':
shouldInclude = this.isClientOnline(client.email);
break;
}
if (shouldInclude) {
this.searchedClients.push(client);
}
});
}
},
toggleFilter() {
if (this.enableFilter) {
this.searchKey = '';
} else {
this.filterBy = '';
this.searchedClients = this.clients.slice();
}
},
isClientDepleted(client) {
const now = new Date().getTime();
const exhausted = client.totalGB > 0 && (client.up || 0) + (client.down || 0) >= client.totalGB * 1024 * 1024 * 1024;
const expired = client.expiryTime > 0 && client.expiryTime <= now;
return expired || exhausted;
},
generalActions(action) {
switch (action.key) {
case "resetClients":
this.resetAllClientTraffics();
break;
case "delDepletedClients":
this.delDepletedClients();
break;
}
},
resetAllClientTraffics() {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
content: '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
try {
const msg = await HttpUtil.post('/panel/client/resetAllTraffics');
if (msg.success) {
app.$message.success('{{ i18n "pages.inbounds.toasts.resetAllClientTrafficSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
} catch (e) {
console.error("Failed to reset all client traffics:", e);
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
});
},
resetClientTraffic(client) {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
try {
const msg = await HttpUtil.post('/panel/client/resetTraffic/' + client.id);
if (msg.success) {
app.$message.success('{{ i18n "pages.inbounds.toasts.resetInboundClientTrafficSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
} catch (e) {
console.error("Failed to reset client traffic:", e);
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
});
},
delDepletedClients() {
this.$confirm({
title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
try {
const msg = await HttpUtil.post('/panel/client/delDepletedClients');
if (msg.success) {
app.$message.success('{{ i18n "pages.inbounds.toasts.delDepletedClientsSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
} catch (e) {
console.error("Failed to delete depleted clients:", e);
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
});
}
},
async mounted() {
// Load default settings (subSettings, remarkModel) first
await this.getDefaultSettings();
// Load available nodes for proper host addresses in QR codes
await this.loadAvailableNodes();
this.loading();
// Initial data fetch
this.loadClients().then(() => {
this.loading(false);
// Initialize searchedClients after first load
this.searchedClients = this.clients.slice();
});
// Setup WebSocket for real-time updates
if (window.wsClient) {
window.wsClient.connect();
// Listen for inbounds updates (contains full client traffic data)
window.wsClient.on('inbounds', (payload) => {
if (payload && Array.isArray(payload)) {
// Update traffic for clients from inbounds data
// This is more efficient than reloading all clients
if (!this.refreshing) {
this.refreshing = true;
// Silently reload clients to get updated traffic
this.loadClients().finally(() => {
this.refreshing = false;
});
}
}
});
// Listen for traffic updates
window.wsClient.on('traffic', (payload) => {
// Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) {
this.onlineClients = payload.onlineClients;
}
// Update last online map in real-time
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
}
// Note: Traffic updates (up/down) are handled via 'inbounds' event
// which contains full accumulated traffic data from database
});
// Fallback to polling if WebSocket fails
window.wsClient.on('error', () => {
console.warn('WebSocket connection failed, falling back to polling');
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
});
window.wsClient.on('disconnected', () => {
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
console.warn('WebSocket reconnection failed, falling back to polling');
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
});
} else {
// Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
},
watch: {
searchKey: Utils.debounce(function (newVal) {
this.searchClients(newVal);
}, 500)
}
});
</script>
{{ template "page/body_end" .}}

View file

@ -12,13 +12,6 @@
<template slot="title">{{ i18n "info" }}</template> <template slot="title">{{ i18n "info" }}</template>
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon> <a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
</a-tooltip> </a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" :style="{ color: 'var(--color-primary-100)'}"></a-icon>
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon>
</a-popconfirm>
</a-tooltip>
<a-tooltip> <a-tooltip>
<template slot="title"> <template slot="title">
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span> <span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
@ -156,10 +149,6 @@
<a-icon :style="{ fontSize: '14px' }" type="info-circle"></a-icon> <a-icon :style="{ fontSize: '14px' }" type="info-circle"></a-icon>
{{ i18n "info" }} {{ i18n "info" }}
</a-menu-item> </a-menu-item>
<a-menu-item @click="resetClientTraffic(client,record.id)" v-if="client.email.length > 0">
<a-icon :style="{ fontSize: '14px' }" type="retweet"></a-icon>
{{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item v-if="isRemovable(record.id)" @click="delClient(record.id,client)"> <a-menu-item v-if="isRemovable(record.id)" @click="delClient(record.id,client)">
<a-icon :style="{ fontSize: '14px' }" type="delete"></a-icon> <a-icon :style="{ fontSize: '14px' }" type="delete"></a-icon>
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span> <span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>

View file

@ -6,7 +6,7 @@
<a-theme-switch></a-theme-switch> <a-theme-switch></a-theme-switch>
<a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab" <a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab"
@click="({key}) => openLink(key)"> @click="({key}) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key"> <a-menu-item v-for="tab in tabs" :key="tab.key" :data-menu-key="tab.key">
<a-icon :type="tab.icon"></a-icon> <a-icon :type="tab.icon"></a-icon>
<span v-text="tab.title"></span> <span v-text="tab.title"></span>
</a-menu-item> </a-menu-item>
@ -20,7 +20,7 @@
<a-theme-switch></a-theme-switch> <a-theme-switch></a-theme-switch>
<a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab" <a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab"
@click="({key}) => openLink(key)"> @click="({key}) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key"> <a-menu-item v-for="tab in tabs" :key="tab.key" :data-menu-key="tab.key">
<a-icon :type="tab.icon"></a-icon> <a-icon :type="tab.icon"></a-icon>
<span v-text="tab.title"></span> <span v-text="tab.title"></span>
</a-menu-item> </a-menu-item>
@ -39,11 +39,35 @@
<script> <script>
const SIDEBAR_COLLAPSED_KEY = "isSidebarCollapsed" const SIDEBAR_COLLAPSED_KEY = "isSidebarCollapsed"
// Get multiNodeMode from server-rendered template
const INITIAL_MULTI_NODE_MODE = {{ if .multiNodeMode }}true{{else}}false{{end}};
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: INITIAL_MULTI_NODE_MODE
}
},
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',
@ -54,6 +78,11 @@
icon: 'user', icon: 'user',
title: '{{ i18n "menu.inbounds"}}' title: '{{ i18n "menu.inbounds"}}'
}, },
{
key: '{{ .base_path }}panel/clients',
icon: 'team',
title: '{{ i18n "menu.clients"}}'
},
{ {
key: '{{ .base_path }}panel/settings', key: '{{ .base_path }}panel/settings',
icon: 'setting', icon: 'setting',
@ -63,21 +92,29 @@
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 and Hosts menu items if multi-node mode is enabled
title: '{{ i18n "menu.logout"}}' if (this.multiNodeMode) {
}, this.tabs.splice(4, 0, {
], key: '{{ .base_path }}panel/nodes',
activeTab: [ icon: 'cluster',
'{{ .request_uri }}' title: '{{ i18n "menu.nodes"}}'
], });
visible: false, this.tabs.splice(5, 0, {
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)), key: '{{ .base_path }}panel/hosts',
} icon: 'cloud-server',
}, title: '{{ i18n "menu.hosts"}}'
methods: { });
}
this.tabs.push({
key: '{{ .base_path }}logout/',
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 +134,13 @@
} }
} }
}, },
mounted() {
this.updateTabs();
// Watch for multi-node mode changes (update tabs if mode changes)
setInterval(() => {
this.loadMultiNodeMode();
}, 5000);
},
template: `{{template "component/sidebar/content"}}`, template: `{{template "component/sidebar/content"}}`,
}); });
</script> </script>

View file

@ -9,7 +9,7 @@
<a-menu-item id="change-theme" class="ant-menu-theme-switch" @mousedown="themeSwitcher.animationsOff()"> <a-menu-item id="change-theme" class="ant-menu-theme-switch" @mousedown="themeSwitcher.animationsOff()">
<span>{{ i18n "menu.dark" }}</span> <span>{{ i18n "menu.dark" }}</span>
<a-switch :style="{ marginLeft: '2px' }" size="small" :default-checked="themeSwitcher.isDarkTheme" <a-switch :style="{ marginLeft: '2px' }" size="small" :default-checked="themeSwitcher.isDarkTheme"
@change="themeSwitcher.toggleTheme()"></a-switch> :disabled="themeSwitcher.isGlassMorphism" @change="themeSwitcher.toggleTheme()"></a-switch>
</a-menu-item> </a-menu-item>
<a-menu-item id="change-theme-ultra" v-if="themeSwitcher.isDarkTheme" class="ant-menu-theme-switch" <a-menu-item id="change-theme-ultra" v-if="themeSwitcher.isDarkTheme" class="ant-menu-theme-switch"
@mousedown="themeSwitcher.animationsOffUltra()"> @mousedown="themeSwitcher.animationsOffUltra()">
@ -17,6 +17,12 @@
<a-checkbox :style="{ marginLeft: '2px' }" :checked="themeSwitcher.isUltra" <a-checkbox :style="{ marginLeft: '2px' }" :checked="themeSwitcher.isUltra"
@click="themeSwitcher.toggleUltra()"></a-checkbox> @click="themeSwitcher.toggleUltra()"></a-checkbox>
</a-menu-item> </a-menu-item>
<a-menu-item id="change-theme-glass" class="ant-menu-theme-switch"
@mousedown="themeSwitcher.animationsOffGlass()">
<span>{{ i18n "menu.glassMorphism" }}</span>
<a-switch :style="{ marginLeft: '2px' }" size="small" :default-checked="themeSwitcher.isGlassMorphism"
@change="themeSwitcher.toggleGlassMorphism()"></a-switch>
</a-menu-item>
</a-sub-menu> </a-sub-menu>
</a-menu> </a-menu>
</template> </template>
@ -26,13 +32,17 @@
<template> <template>
<a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }"> <a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
<a-space direction="horizontal" size="small"> <a-space direction="horizontal" size="small">
<a-switch size="small" :default-checked="themeSwitcher.isDarkTheme" @change="themeSwitcher.toggleTheme()"></a-switch> <a-switch size="small" :default-checked="themeSwitcher.isDarkTheme" :disabled="themeSwitcher.isGlassMorphism" @change="themeSwitcher.toggleTheme()"></a-switch>
<span>{{ i18n "menu.dark" }}</span> <span>{{ i18n "menu.dark" }}</span>
</a-space> </a-space>
<a-space v-if="themeSwitcher.isDarkTheme" direction="horizontal" size="small"> <a-space v-if="themeSwitcher.isDarkTheme" direction="horizontal" size="small">
<a-checkbox :checked="themeSwitcher.isUltra" @click="themeSwitcher.toggleUltra()"></a-checkbox> <a-checkbox :checked="themeSwitcher.isUltra" @click="themeSwitcher.toggleUltra()"></a-checkbox>
<span>{{ i18n "menu.ultraDark" }}</span> <span>{{ i18n "menu.ultraDark" }}</span>
</a-space> </a-space>
<a-space direction="horizontal" size="small">
<a-switch size="small" :default-checked="themeSwitcher.isGlassMorphism" @change="themeSwitcher.toggleGlassMorphism()"></a-switch>
<span>{{ i18n "menu.glassMorphism" }}</span>
</a-space>
</a-space> </a-space>
</template> </template>
{{end}} {{end}}
@ -40,10 +50,34 @@
{{define "component/aThemeSwitch"}} {{define "component/aThemeSwitch"}}
<script> <script>
function createThemeSwitcher() { function createThemeSwitcher() {
const isDarkTheme = localStorage.getItem('dark-mode') === 'true'; let isDarkTheme = localStorage.getItem('dark-mode') === 'true';
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true'; let isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
if (isUltra) { // Glass Morphism включен по умолчанию, если не установлено явно
document.documentElement.setAttribute('data-theme', 'ultra-dark'); let isGlassMorphism = localStorage.getItem('isGlassMorphismEnabled');
if (isGlassMorphism === null) {
isGlassMorphism = true; // По умолчанию включен
localStorage.setItem('isGlassMorphismEnabled', 'true');
} else {
isGlassMorphism = isGlassMorphism === 'true';
}
// Если включен Glass Morphism, отключаем темную тему
if (isGlassMorphism) {
isDarkTheme = false;
isUltra = false;
localStorage.setItem('dark-mode', 'false');
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
document.documentElement.setAttribute('data-glass-morphism', 'true');
document.documentElement.removeAttribute('data-theme');
} else {
// Если включена темная тема, отключаем Glass Morphism
if (isDarkTheme) {
isGlassMorphism = false;
localStorage.setItem('isGlassMorphismEnabled', 'false');
document.documentElement.removeAttribute('data-glass-morphism');
}
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
}
} }
const theme = isDarkTheme ? 'dark' : 'light'; const theme = isDarkTheme ? 'dark' : 'light';
document.querySelector('body').setAttribute('class', theme); document.querySelector('body').setAttribute('class', theme);
@ -68,13 +102,33 @@
document.documentElement.removeAttribute('data-theme-animations'); document.documentElement.removeAttribute('data-theme-animations');
}); });
}, },
animationsOffGlass() {
document.documentElement.setAttribute('data-theme-animations', 'off');
const themeAnimationsGlass = document.querySelector('#change-theme-glass');
themeAnimationsGlass.addEventListener('mouseleave', () => {
document.documentElement.removeAttribute('data-theme-animations');
});
themeAnimationsGlass.addEventListener('touchend', () => {
document.documentElement.removeAttribute('data-theme-animations');
});
},
isDarkTheme, isDarkTheme,
isUltra, isUltra,
isGlassMorphism,
get currentTheme() { get currentTheme() {
return this.isDarkTheme ? 'dark' : 'light'; return this.isDarkTheme ? 'dark' : 'light';
}, },
toggleTheme() { toggleTheme() {
if (this.isGlassMorphism) {
return; // Не позволяем включать темную тему когда включен Glass Morphism
}
this.isDarkTheme = !this.isDarkTheme; this.isDarkTheme = !this.isDarkTheme;
if (this.isDarkTheme) {
// Если включаем темную тему, отключаем Glass Morphism
this.isGlassMorphism = false;
document.documentElement.removeAttribute('data-glass-morphism');
localStorage.setItem('isGlassMorphismEnabled', 'false');
}
localStorage.setItem('dark-mode', this.isDarkTheme); localStorage.setItem('dark-mode', this.isDarkTheme);
document.querySelector('body').setAttribute('class', this.isDarkTheme ? 'dark' : 'light'); document.querySelector('body').setAttribute('class', this.isDarkTheme ? 'dark' : 'light');
document.getElementById('message').className = themeSwitcher.currentTheme; document.getElementById('message').className = themeSwitcher.currentTheme;
@ -87,6 +141,23 @@
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
} }
localStorage.setItem('isUltraDarkThemeEnabled', this.isUltra.toString()); localStorage.setItem('isUltraDarkThemeEnabled', this.isUltra.toString());
},
toggleGlassMorphism() {
this.isGlassMorphism = !this.isGlassMorphism;
if (this.isGlassMorphism) {
// Если включаем Glass Morphism, отключаем темную тему
this.isDarkTheme = false;
document.querySelector('body').setAttribute('class', 'light');
document.documentElement.removeAttribute('data-theme');
this.isUltra = false;
localStorage.setItem('dark-mode', 'false');
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
document.documentElement.setAttribute('data-glass-morphism', 'true');
document.getElementById('message').className = 'light';
} else {
document.documentElement.removeAttribute('data-glass-morphism');
}
localStorage.setItem('isGlassMorphismEnabled', this.isGlassMorphism.toString());
} }
}; };
} }

View file

@ -123,7 +123,7 @@
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input-number v-model.number="client._totalGB" :min="0"></a-input-number> <a-input-number v-model.number="client._totalGB" :min="0" :step="0.01" :precision="2"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'> <a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)"> <a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">

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>
@ -41,7 +61,7 @@
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number> <a-input-number v-model.number="dbInbound.totalGB" :min="0" :step="0.01" :precision="2"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>

View file

@ -1,11 +1,6 @@
{{define "form/shadowsocks"}} {{define "form/shadowsocks"}}
<template v-if="inbound.isSSMultiUser"> <template v-if="inbound.isSSMultiUser">
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit"> <a-collapse v-if="isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.shadowsockses.length"> <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.shadowsockses.length">
<table width="100%"> <table width="100%">
<tr class="client-table-header"> <tr class="client-table-header">

View file

@ -1,10 +1,5 @@
{{define "form/trojan"}} {{define "form/trojan"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit"> <a-collapse v-if="isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.trojans.length"> <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.trojans.length">
<table width="100%"> <table width="100%">
<tr class="client-table-header"> <tr class="client-table-header">

View file

@ -1,10 +1,5 @@
{{define "form/vless"}} {{define "form/vless"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit"> <a-collapse v-if="isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' +
inbound.settings.vlesses.length"> inbound.settings.vlesses.length">
<table width="100%"> <table width="100%">

View file

@ -1,10 +1,5 @@
{{define "form/vmess"}} {{define "form/vmess"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit"> <a-collapse v-if="isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vmesses.length"> <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vmesses.length">
<table width="100%"> <table width="100%">
<tr class="client-table-header"> <tr class="client-table-header">

397
web/html/hosts.html Normal file
View file

@ -0,0 +1,397 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' hosts-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content :style="{ padding: '24px 16px' }">
<transition name="list" appear>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched && multiNodeMode">
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>{{ i18n "pages.hosts.title" }}</h2>
<div style="margin-bottom: 20px;">
<a-button type="primary" icon="plus" @click="openAddHost">{{ i18n "pages.hosts.addNewHost" }}</a-button>
</div>
<div style="margin-bottom: 20px;">
<a-button icon="sync" @click="loadHosts" :loading="refreshing">{{ i18n "refresh" }}</a-button>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="host => host.id"
:data-source="hosts" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="hosts-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, host">
<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, host)"
:theme="themeSwitcher.currentTheme">
<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="enable" slot-scope="text, host">
<a-switch v-model="host.enable" @change="switchEnable(host.id, host.enable)"></a-switch>
</template>
<template slot="inbounds" slot-scope="text, host">
<template v-if="host.inbounds && host.inbounds.length > 0">
<a-tag v-for="(inbound, index) in host.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>
</a-table>
</a-card>
</a-col>
</a-row>
<a-row v-else-if="!multiNodeMode">
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-alert type="info" message='{{ i18n "pages.hosts.multiNodeModeRequired" }}' show-icon></a-alert>
</a-card>
</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>
</transition>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "modals/hostModal"}}
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 50,
}, {
title: '{{ i18n "pages.hosts.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.hosts.name" }}',
align: 'left',
width: 150,
dataIndex: "name",
}, {
title: '{{ i18n "pages.hosts.address" }}',
align: 'left',
width: 200,
dataIndex: "address",
}, {
title: '{{ i18n "pages.hosts.port" }}',
align: 'center',
width: 80,
dataIndex: "port",
}, {
title: '{{ i18n "pages.hosts.protocol" }}',
align: 'center',
width: 80,
dataIndex: "protocol",
}, {
title: '{{ i18n "pages.hosts.assignedInbounds" }}',
align: 'left',
width: 300,
scopedSlots: { customRender: 'inbounds' },
}, {
title: '{{ i18n "pages.hosts.enable" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'enable' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
}, {
title: '{{ i18n "pages.hosts.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.hosts.name" }}',
align: 'left',
width: 100,
dataIndex: "name",
}, {
title: '{{ i18n "pages.hosts.enable" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'enable' },
}];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
hosts: [],
refreshing: false,
multiNodeMode: false,
allInbounds: [],
},
methods: {
async loadMultiNodeMode() {
try {
const msg = await HttpUtil.post('/panel/setting/all');
if (msg && msg.success && msg.obj) {
this.multiNodeMode = msg.obj.multiNodeMode || false;
}
} catch (e) {
console.error("Failed to load multi-node mode:", e);
}
},
async loadHosts() {
if (!this.multiNodeMode) {
this.loadingStates.fetched = true;
return;
}
this.refreshing = true;
try {
const msg = await HttpUtil.get('/panel/host/list');
if (msg && msg.success && msg.obj) {
this.hosts = msg.obj;
// Load inbounds for each host
await this.loadInboundsForHosts();
}
} catch (e) {
console.error("Failed to load hosts:", e);
app.$message.error('{{ i18n "pages.hosts.loadError" }}');
} finally {
this.refreshing = false;
this.loadingStates.fetched = true;
}
},
async loadInboundsForHosts() {
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
const allInbounds = inboundsMsg.obj;
// Map inbound IDs to full inbound objects for each host
this.hosts.forEach(host => {
if (host.inboundIds && Array.isArray(host.inboundIds)) {
host.inbounds = host.inboundIds.map(id => {
return allInbounds.find(ib => ib.id === id);
}).filter(ib => ib != null);
} else {
host.inbounds = [];
}
});
}
} catch (e) {
console.error("Failed to load inbounds for hosts:", e);
}
},
clickAction(action, host) {
switch (action.key) {
case 'edit':
this.editHost(host);
break;
case 'delete':
this.deleteHost(host.id);
break;
}
},
async editHost(host) {
// Load all inbounds for selection
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
// Store inbounds in app for modal access
if (!this.allInbounds) {
this.allInbounds = [];
}
this.allInbounds = inboundsMsg.obj;
}
} catch (e) {
console.error("Failed to load inbounds:", e);
}
window.hostModal.show({
title: '{{ i18n "pages.hosts.editHost" }}',
okText: '{{ i18n "update" }}',
host: host,
confirm: async (data) => {
await this.updateHost(host.id, data);
},
isEdit: true
});
},
async updateHost(id, data) {
try {
const msg = await HttpUtil.post(`/panel/host/update/${id}`, data);
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.updateSuccess" }}');
window.hostModal.close();
await this.loadHosts();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.updateError" }}');
window.hostModal.loading(false);
}
} catch (e) {
console.error("Failed to update host:", e);
app.$message.error('{{ i18n "pages.hosts.updateError" }}');
hostModal.loading(false);
}
},
async deleteHost(id) {
this.$confirm({
title: '{{ i18n "pages.hosts.deleteConfirm" }}',
content: '{{ i18n "pages.hosts.deleteConfirmText" }}',
okText: '{{ i18n "sure" }}',
okType: 'danger',
cancelText: '{{ i18n "close" }}',
onOk: async () => {
try {
const msg = await HttpUtil.post(`/panel/host/del/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.deleteSuccess" }}');
await this.loadHosts();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.deleteError" }}');
}
} catch (e) {
console.error("Failed to delete host:", e);
app.$message.error('{{ i18n "pages.hosts.deleteError" }}');
}
}
});
},
async addHostSubmit(data) {
try {
const msg = await HttpUtil.post('/panel/host/add', data);
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.addSuccess" }}');
window.hostModal.close();
await this.loadHosts();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.addError" }}');
window.hostModal.loading(false);
}
} catch (e) {
console.error("Failed to add host:", e);
app.$message.error('{{ i18n "pages.hosts.addError" }}');
hostModal.loading(false);
}
},
async switchEnable(id, enable) {
try {
const msg = await HttpUtil.post(`/panel/host/update/${id}`, { enable: enable });
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.updateSuccess" }}');
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.updateError" }}');
// Revert switch
const host = this.hosts.find(h => h.id === id);
if (host) {
host.enable = !enable;
}
}
} catch (e) {
console.error("Failed to update host:", e);
app.$message.error('{{ i18n "pages.hosts.updateError" }}');
// Revert switch
const host = this.hosts.find(h => h.id === id);
if (host) {
host.enable = !enable;
}
}
},
async openAddHost() {
// Load all inbounds for selection
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
// Store inbounds in app for modal access
if (!this.allInbounds) {
this.allInbounds = [];
}
this.allInbounds = inboundsMsg.obj;
// Also update hostModalApp if it exists
if (window.hostModalApp && window.hostModalApp.app) {
window.hostModalApp.app.allInbounds = inboundsMsg.obj;
}
}
} catch (e) {
console.error("Failed to load inbounds:", e);
}
// Ensure hostModal is available
if (typeof window.hostModal === 'undefined' || !window.hostModal) {
console.error("hostModal is not defined");
this.$message.error('{{ i18n "pages.hosts.modalNotAvailable" }}');
return;
}
window.hostModal.show({
title: '{{ i18n "pages.hosts.addHost" }}',
okText: '{{ i18n "create" }}',
confirm: async (data) => {
await this.addHostSubmit(data);
},
isEdit: false
});
}
},
async mounted() {
await this.loadMultiNodeMode();
await this.loadHosts();
}
});
async function addHost() {
// Load all inbounds for selection
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
// Store inbounds in app for modal access
if (!app.allInbounds) {
app.allInbounds = [];
}
app.allInbounds = inboundsMsg.obj;
}
} catch (e) {
console.error("Failed to load inbounds:", e);
}
window.hostModal.show({
title: '{{ i18n "pages.hosts.addHost" }}',
okText: '{{ i18n "create" }}',
confirm: async (data) => {
await app.addHostSubmit(data);
},
isEdit: false
});
}
</script>
{{ template "page/body_end" .}}

View file

@ -123,18 +123,6 @@
<a-icon type="export"></a-icon> <a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }} {{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="resetInbounds">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetAllTraffic" }}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@ -204,18 +192,6 @@
{{ i18n "qrCode" }} {{ i18n "qrCode" }}
</a-menu-item> </a-menu-item>
<template v-if="dbInbound.isMultiUser()"> <template v-if="dbInbound.isMultiUser()">
<a-menu-item key="addClient">
<a-icon type="user-add"></a-icon>
{{ i18n "pages.client.add"}}
</a-menu-item>
<a-menu-item key="addBulkClient">
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetInboundClientTraffics"}}
</a-menu-item>
<a-menu-item key="export"> <a-menu-item key="export">
<a-icon type="export"></a-icon> <a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} {{ i18n "pages.inbounds.export"}}
@ -224,10 +200,6 @@
<a-icon type="export"></a-icon> <a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }} {{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</template> </template>
<template v-else> <template v-else>
<a-menu-item key="showInfo"> <a-menu-item key="showInfo">
@ -239,9 +211,6 @@
<a-icon type="copy"></a-icon> <a-icon type="copy"></a-icon>
{{ i18n "pages.inbounds.exportInbound" }} {{ i18n "pages.inbounds.exportInbound" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="retweet"></a-icon> {{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item key="clone"> <a-menu-item key="clone">
<a-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}} <a-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}}
</a-menu-item> </a-menu-item>
@ -353,19 +322,12 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td> <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td> <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr> </tr>
<tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total"> <!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td>
</tr>
</table> </table>
</template> </template>
<a-tag <a-tag>
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)"> [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / <template v-if="false">
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
@ -375,9 +337,6 @@
</a-tag> </a-tag>
</a-popover> </a-popover>
</template> </template>
<template slot="allTimeInbound" slot-scope="text, dbInbound">
<a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag>
</template>
<template slot="enable" slot-scope="text, dbInbound"> <template slot="enable" slot-scope="text, dbInbound">
<a-switch v-model="dbInbound.enable" <a-switch v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch> @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
@ -516,21 +475,12 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td> <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td> <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr> </tr>
<tr <!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down)
]]</td>
</tr>
</table> </table>
</template> </template>
<a-tag <a-tag>
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)"> [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / <template v-if="false">
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
@ -563,6 +513,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>
@ -574,13 +537,6 @@
</a-badge> </a-badge>
</a-popover> </a-popover>
</template> </template>
<template slot="expandedRowRender" slot-scope="record">
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable"}}
</a-table>
</template>
</a-table> </a-table>
</a-space> </a-space>
</a-card> </a-card>
@ -650,11 +606,6 @@
align: 'center', align: 'center',
width: 90, width: 90,
scopedSlots: { customRender: 'traffic' }, scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'allTimeInbound' },
}, { }, {
title: '{{ i18n "pages.inbounds.expireDate" }}', title: '{{ i18n "pages.inbounds.expireDate" }}',
align: 'center', align: 'center',
@ -691,7 +642,6 @@
{ title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } }, { title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } }, { title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } }, { title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 60, align: 'center', scopedSlots: { customRender: 'allTime' } },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } }, { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
]; ];
@ -706,6 +656,8 @@
el: '#app', el: '#app',
mixins: [MediaQueryMixin], mixins: [MediaQueryMixin],
data: { data: {
availableNodes: [],
multiNodeMode: false,
themeSwitcher, themeSwitcher,
persianDatepicker, persianDatepicker,
loadingStates: { loadingStates: {
@ -746,6 +698,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 +794,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);
@ -938,15 +933,6 @@
case "subs": case "subs":
this.exportAllSubs(); this.exportAllSubs();
break; break;
case "resetInbounds":
this.resetAllTraffic();
break;
case "resetClients":
this.resetAllClientTraffics(-1);
break;
case "delDepletedClients":
this.delDepletedClients(-1)
break;
} }
}, },
clickAction(action, dbInbound) { clickAction(action, dbInbound) {
@ -960,12 +946,6 @@
case "edit": case "edit":
this.openEditInbound(dbInbound.id); this.openEditInbound(dbInbound.id);
break; break;
case "addClient":
this.openAddClient(dbInbound.id)
break;
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
case "export": case "export":
this.inboundLinks(dbInbound.id); this.inboundLinks(dbInbound.id);
break; break;
@ -975,21 +955,12 @@
case "clipboard": case "clipboard":
this.copy(dbInbound.id); this.copy(dbInbound.id);
break; break;
case "resetTraffic":
this.resetTraffic(dbInbound.id);
break;
case "resetClients":
this.resetAllClientTraffics(dbInbound.id);
break;
case "clone": case "clone":
this.openCloneInbound(dbInbound); this.openCloneInbound(dbInbound);
break; break;
case "delete": case "delete":
this.delInbound(dbInbound.id); this.delInbound(dbInbound.id);
break; break;
case "delDepletedClients":
this.delDepletedClients(dbInbound.id)
break;
} }
}, },
openCloneInbound(dbInbound) { openCloneInbound(dbInbound) {
@ -1041,6 +1012,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"}}',
@ -1076,6 +1061,14 @@
} }
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);
}, },
async updateInbound(inbound, dbInbound) { async updateInbound(inbound, dbInbound) {
@ -1101,6 +1094,21 @@
} }
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);
}, },
openAddClient(dbInboundId) { openAddClient(dbInboundId) {
@ -1252,6 +1260,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 +1324,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 +1596,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>
@ -379,6 +392,15 @@
</a-icon> </a-icon>
</template> </template>
<a-form layout="inline"> <a-form layout="inline">
<a-form-item class="mr-05" v-if="multiNodeMode" label="Node:">
<a-select size="small" v-model="xraylogModal.nodeId" :style="{ width: '180px' }" @change="openXrayLogs()"
:dropdown-class-name="themeSwitcher.currentTheme" placeholder="Select Node">
<a-select-option value="">All Nodes</a-select-option>
<a-select-option v-for="node in xraylogModal.nodes" :key="node.id" :value="node.id.toString()">
[[ node.name || 'Node ' + node.id ]]
</a-select-option>
</a-select>
</a-form-item>
<a-form-item class="mr-05"> <a-form-item class="mr-05">
<a-input-group compact> <a-input-group compact>
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()" <a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
@ -685,6 +707,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 +730,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 +754,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 = {
@ -797,10 +843,14 @@
visible: false, visible: false,
logs: [], logs: [],
rows: 20, rows: 20,
filter: '',
showDirect: true, showDirect: true,
showBlocked: true, showBlocked: true,
showProxy: true, showProxy: true,
loading: false, loading: false,
multiNodeMode: false,
nodes: [],
nodeId: '',
show(logs) { show(logs) {
this.visible = true; this.visible = true;
this.logs = logs; this.logs = logs;
@ -895,12 +945,43 @@
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;
xraylogModal.multiNodeMode = this.multiNodeMode;
// Load nodes if multi-node mode is enabled
if (this.multiNodeMode) {
await this.loadNodesForLogs();
}
}
} catch (e) {
console.warn("Failed to load multi-node mode:", e);
}
},
async loadNodesForLogs() {
try {
const msg = await HttpUtil.get("/panel/node/list");
if (msg && msg.success && msg.obj) {
xraylogModal.nodes = msg.obj.map(node => ({
id: node.id,
name: node.name || 'Node ' + node.id,
address: node.address || '',
status: node.status || 'unknown'
}));
}
} catch (e) {
console.warn("Failed to load nodes for logs:", e);
}
},
async getStatus() { async getStatus() {
try { try {
const msg = await HttpUtil.get('/panel/api/server/status'); const msg = await HttpUtil.get('/panel/api/server/status');
@ -1027,12 +1108,45 @@
logModal.loading = false; logModal.loading = false;
}, },
async openXrayLogs() { async openXrayLogs() {
xraylogModal.loading = true; // Ensure multi-node mode is loaded and nodes are available
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy }); if (this.multiNodeMode && xraylogModal.nodes.length === 0) {
if (!msg.success) { await this.loadNodesForLogs();
return; }
xraylogModal.loading = true;
const params = {
filter: xraylogModal.filter,
showDirect: xraylogModal.showDirect,
showBlocked: xraylogModal.showBlocked,
showProxy: xraylogModal.showProxy
};
// If multi-node mode and nodeId is selected, use node-specific endpoint
if (this.multiNodeMode && xraylogModal.nodeId) {
const msg = await HttpUtil.post('/panel/node/logs/' + xraylogModal.nodeId, {
count: xraylogModal.rows,
filter: xraylogModal.filter,
showDirect: xraylogModal.showDirect,
showBlocked: xraylogModal.showBlocked,
showProxy: xraylogModal.showProxy
});
if (!msg.success) {
xraylogModal.loading = false;
return;
}
xraylogModal.show(msg.obj);
} else {
// Use standard endpoint with optional nodeId
if (xraylogModal.nodeId) {
params.nodeId = xraylogModal.nodeId;
}
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, params);
if (!msg.success) {
xraylogModal.loading = false;
return;
}
xraylogModal.show(msg.obj);
} }
xraylogModal.show(msg.obj);
await PromiseUtil.sleep(500); await PromiseUtil.sleep(500);
xraylogModal.loading = false; xraylogModal.loading = false;
}, },
@ -1117,6 +1231,13 @@
}, 2000); }, 2000);
}, },
}, },
watch: {
'xraylogModal.visible'(newVal) {
if (newVal && this.multiNodeMode && xraylogModal.nodes.length === 0) {
this.loadNodesForLogs();
}
}
},
async mounted() { async mounted() {
if (window.location.protocol !== "https:") { if (window.location.protocol !== "https:") {
this.showAlert = true; this.showAlert = true;
@ -1127,6 +1248,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

@ -123,6 +123,8 @@
this.loadingStates.spinning = true; this.loadingStates.spinning = true;
const msg = await HttpUtil.post('/login', this.user); const msg = await HttpUtil.post('/login', this.user);
if (msg.success) { if (msg.success) {
// Устанавливаем флаг для показа popup "Что нового?" после логина
sessionStorage.setItem('showWhatsNew', 'true');
location.href = basePath + 'panel/'; location.href = basePath + 'panel/';
} }
this.loadingStates.spinning = false; this.loadingStates.spinning = false;

View file

@ -85,7 +85,7 @@
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0"></a-input-number> <a-input-number v-model.number="clientsBulkModal.totalGB" :min="0" :step="0.01" :precision="2"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'> <a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
<a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch> <a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch>

View file

@ -0,0 +1,307 @@
{{define "modals/clientEntityModal"}}
<a-modal id="client-entity-modal" v-model="clientEntityModal.visible" :title="clientEntityModal.title" @ok="clientEntityModal.ok"
:confirm-loading="clientEntityModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme"
:ok-text="clientEntityModal.okText" cancel-text='{{ i18n "close" }}' :width="600">
<a-form layout="vertical" v-if="client">
<a-form-item label='{{ i18n "pages.clients.email" }}' :required="true">
<a-input v-model.trim="client.email" :disabled="clientEntityModal.isEdit"></a-input>
</a-form-item>
<a-form-item label='UUID/ID'>
<a-input v-model.trim="client.uuid">
<a-icon slot="suffix" type="sync" @click="client.uuid = RandomUtil.randomUUID()" style="cursor: pointer;"></a-icon>
</a-input>
</a-form-item>
<a-form-item label='{{ i18n "password" }}'>
<a-input v-model.trim="client.password">
<a-icon slot="suffix" type="sync" @click="client.password = RandomUtil.randomSeq(10)" style="cursor: pointer;"></a-icon>
</a-input>
</a-form-item>
<a-form-item label='{{ i18n "security" }}'>
<a-select v-model="client.security" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="">{{ i18n "none" }}</a-select-option>
<a-select-option v-for="key in USERS_SECURITY" :key="key" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='Flow'>
<a-select v-model="client.flow" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="">{{ i18n "none" }}</a-select-option>
<a-select-option v-for="key in TLS_FLOW_CONTROL" :key="key" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='Subscription ID'>
<a-input v-model.trim="client.subId">
<a-icon slot="suffix" type="sync" @click="client.subId = RandomUtil.randomLowerAndNum(16)" style="cursor: pointer;"></a-icon>
</a-input>
</a-form-item>
<a-form-item label='{{ i18n "comment" }}'>
<a-input v-model.trim="client.comment"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.IPLimit" }}'>
<a-input-number v-model.number="client.limitIp" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.totalFlow" }} (GB)'>
<a-input-number v-model.number="client.totalGB" :min="0" :step="0.01" :precision="2" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.expireDate" }}'>
<a-date-picker :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
:dropdown-class-name="themeSwitcher.currentTheme" v-model="client._expiryTime" :style="{ width: '100%' }"></a-date-picker>
</a-form-item>
<a-form-item label='Telegram ChatID'>
<a-input-number v-model.number="client.tgId" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.clients.inbounds" }}'>
<a-select v-model="client.inboundIds" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option v-for="inbound in app.allInbounds" :key="inbound.id" :value="inbound.id">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-select-option>
</a-select>
</a-form-item>
<a-divider>{{ i18n "hwidSettings" }}</a-divider>
<a-alert
message='{{ i18n "hwidBetaWarningTitle" }}'
description='{{ i18n "hwidBetaWarningDesc" }}'
type="warning"
show-icon
:closable="false"
style="margin-bottom: 16px;">
</a-alert>
<a-form-item label='{{ i18n "hwidEnabled" }}'>
<a-switch v-model="client.hwidEnabled"></a-switch>
</a-form-item>
<a-form-item label='{{ i18n "maxHwid" }}' v-if="client.hwidEnabled">
<a-input-number v-model.number="client.maxHwid" :min="0" :style="{ width: '100%' }">
<template slot="addonAfter">
<a-tooltip>
<template slot="title">0 = {{ i18n "unlimited" }}</template>
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
</a-input-number>
</a-form-item>
<a-form-item v-if="client.hwidEnabled && clientEntityModal.isEdit">
<a-table
:columns="hwidColumns"
:data-source="client.hwids"
:pagination="false"
size="small"
:style="{ marginTop: '10px' }">
<template slot="deviceInfo" slot-scope="text, record">
<div>
<div><strong>[[ record.deviceModel || record.deviceName || record.deviceOs || 'Unknown Device' ]]</strong></div>
<small style="color: #999;">HWID: [[ record.hwid ]]</small>
</div>
</template>
<template slot="status" slot-scope="text, record">
<a-tag v-if="record.isActive" color="green">{{ i18n "active" }}</a-tag>
<a-tag v-else>{{ i18n "inactive" }}</a-tag>
</template>
<template slot="firstSeen" slot-scope="text, record">
[[ clientEntityModal.formatTimestamp(record.firstSeenAt || record.firstSeen) ]]
</template>
<template slot="lastSeen" slot-scope="text, record">
[[ clientEntityModal.formatTimestamp(record.lastSeenAt || record.lastSeen) ]]
</template>
<template slot="actions" slot-scope="text, record">
<a-button type="danger" size="small" @click="clientEntityModal.removeHwid(record.id)">{{ i18n "delete" }}</a-button>
</template>
</a-table>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
<a-switch v-model="client.enable"></a-switch>
</a-form-item>
</a-form>
</a-modal>
<script>
const clientEntityModal = window.clientEntityModal = {
visible: false,
confirmLoading: false,
title: '',
okText: '{{ i18n "sure" }}',
isEdit: false,
client: null,
confirm: null,
ok() {
if (clientEntityModal.confirm && clientEntityModal.client) {
const client = clientEntityModal.client;
if (typeof ObjectUtil !== 'undefined' && ObjectUtil.execute) {
ObjectUtil.execute(clientEntityModal.confirm, client);
} else {
clientEntityModal.confirm(client);
}
}
},
show({ title = '', okText = '{{ i18n "sure" }}', client = null, confirm = () => {}, isEdit = false }) {
this.title = title;
this.okText = okText;
this.isEdit = isEdit;
this.confirm = confirm;
if (client) {
// Edit mode - use provided client data
this.client = {
id: client.id,
email: client.email || '',
uuid: client.uuid || '',
password: client.password || '',
security: client.security || 'auto',
flow: client.flow || '',
subId: client.subId || '',
comment: client.comment || '',
limitIp: client.limitIp || 0,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
_expiryTime: client.expiryTime > 0 ? (moment ? moment(client.expiryTime) : new Date(client.expiryTime)) : null,
tgId: client.tgId || 0,
inboundIds: client.inboundIds ? [...client.inboundIds] : [],
enable: client.enable !== undefined ? client.enable : true,
hwidEnabled: client.hwidEnabled !== undefined ? client.hwidEnabled : false,
maxHwid: client.maxHwid !== undefined ? client.maxHwid : 1,
hwids: client.hwids ? [...client.hwids] : []
};
// If in edit mode, load HWIDs from API
if (isEdit && client.id) {
this.loadClientHWIDs(client.id);
}
} else {
// Add mode - create new client
this.client = {
email: '',
uuid: RandomUtil.randomUUID(),
password: RandomUtil.randomSeq(10),
security: 'auto',
flow: '',
subId: RandomUtil.randomLowerAndNum(16),
comment: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
_expiryTime: null,
tgId: 0,
inboundIds: [],
enable: true,
hwidEnabled: false,
maxHwid: 1
};
}
this.visible = true;
},
close() {
this.visible = false;
this.loading(false);
},
loading(loading = true) {
this.confirmLoading = loading;
},
async loadClientHWIDs(clientId) {
try {
const msg = await HttpUtil.get(`/panel/client/hwid/list/${clientId}`);
if (msg && msg.success && msg.obj) {
if (this.client) {
this.client.hwids = msg.obj || [];
}
}
} catch (e) {
console.error("Failed to load client HWIDs:", e);
if (this.client) {
this.client.hwids = [];
}
}
},
formatTimestamp(timestamp) {
if (!timestamp) return '-';
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
return IntlUtil.formatDate(timestamp);
}
return new Date(timestamp * 1000).toLocaleString();
},
async removeHwid(hwidId) {
if (!confirm('{{ i18n "pages.clients.confirmDeleteHwid" }}')) {
return;
}
try {
const msg = await HttpUtil.post(`/panel/client/hwid/remove/${hwidId}`);
if (msg.success) {
if (typeof app !== 'undefined') {
app.$message.success('{{ i18n "pages.clients.hwidDeleteSuccess" }}');
}
// Reload client HWIDs
if (this.client && this.client.id) {
await this.loadClientHWIDs(this.client.id);
}
} else {
if (typeof app !== 'undefined') {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
}
} catch (e) {
console.error("Failed to delete HWID:", e);
if (typeof app !== 'undefined') {
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
}
};
const clientEntityModalApp = window.clientEntityModalApp = new Vue({
delimiters: ['[[', ']]'],
el: '#client-entity-modal',
data: {
clientEntityModal: clientEntityModal,
},
computed: {
client() {
return this.clientEntityModal.client;
},
themeSwitcher() {
return typeof themeSwitcher !== 'undefined' ? themeSwitcher : { currentTheme: 'light' };
},
app() {
return typeof app !== 'undefined' ? app : null;
},
USERS_SECURITY() {
return typeof USERS_SECURITY !== 'undefined' ? USERS_SECURITY : {};
},
TLS_FLOW_CONTROL() {
return typeof TLS_FLOW_CONTROL !== 'undefined' ? TLS_FLOW_CONTROL : {};
},
hwidColumns() {
return [
{
title: '{{ i18n "pages.clients.deviceInfo" }}',
align: 'left',
width: 200,
scopedSlots: { customRender: 'deviceInfo' }
},
{
title: '{{ i18n "status" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'status' }
},
{
title: '{{ i18n "pages.clients.firstSeen" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'firstSeen' }
},
{
title: '{{ i18n "pages.clients.lastSeen" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'lastSeen' }
},
{
title: '{{ i18n "pages.clients.actions" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'actions' }
}
];
}
}
});
</script>
{{end}}

View file

@ -1,4 +1,9 @@
{{define "modals/clientsModal"}} {{define "modals/clientsModal"}}
<!--
NOTE: This modal is for backward compatibility with old client architecture (clients stored in inbound.settings).
New clients should be created/edited using clientEntityModal in clients.html.
This modal is still used for editing existing clients in old inbounds.
-->
<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok" <a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false" :confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme" :class="themeSwitcher.currentTheme"
@ -10,6 +15,8 @@
</a-modal> </a-modal>
<script> <script>
// NOTE: This modal is for backward compatibility with old client architecture.
// New clients should use clientEntityModal (see clients.html).
const clientModal = { const clientModal = {
visible: false, visible: false,
confirmLoading: false, confirmLoading: false,

View file

@ -0,0 +1,153 @@
{{define "modals/hostModal"}}
<a-modal id="host-modal" v-model="hostModal.visible" :title="hostModal.title" @ok="hostModal.ok"
:confirm-loading="hostModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme"
:ok-text="hostModal.okText" cancel-text='{{ i18n "close" }}' :width="600">
<a-form layout="vertical" v-if="hostModal.formData">
<a-form-item label='{{ i18n "pages.hosts.hostName" }}' :required="true">
<a-input v-model.trim="hostModal.formData.name" placeholder='{{ i18n "pages.hosts.enterHostName" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.hostAddress" }}' :required="true">
<a-input v-model.trim="hostModal.formData.address" placeholder='{{ i18n "pages.hosts.enterHostAddress" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.hostPort" }}'>
<a-input-number v-model.number="hostModal.formData.port" :min="0" :max="65535" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.hostProtocol" }}'>
<a-select v-model="hostModal.formData.protocol" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.assignedInbounds" }}'>
<a-select v-model="hostModal.formData.inboundIds" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option v-for="inbound in allInbounds" :key="inbound.id" :value="inbound.id">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.enable" }}'>
<a-switch v-model="hostModal.formData.enable"></a-switch>
</a-form-item>
</a-form>
</a-modal>
<script>
const hostModal = window.hostModal = {
visible: false,
confirmLoading: false,
title: '',
okText: '{{ i18n "sure" }}',
isEdit: false,
confirm: null,
formData: {
name: '',
address: '',
port: 0,
protocol: 'tcp',
inboundIds: [],
enable: true
},
ok() {
// Validate form data
if (!hostModal.formData.name || !hostModal.formData.name.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.hosts.enterHostName" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.hosts.enterHostName" }}');
}
return;
}
if (!hostModal.formData.address || !hostModal.formData.address.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.hosts.enterHostAddress" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.hosts.enterHostAddress" }}');
}
return;
}
// Ensure inboundIds is always an array
const dataToSend = { ...hostModal.formData };
if (dataToSend.inboundIds && !Array.isArray(dataToSend.inboundIds)) {
dataToSend.inboundIds = [dataToSend.inboundIds];
} else if (!dataToSend.inboundIds) {
dataToSend.inboundIds = [];
}
hostModal.confirmLoading = true;
if (hostModal.confirm) {
try {
const result = hostModal.confirm(dataToSend);
// If confirm returns a promise, handle it
if (result && typeof result.then === 'function') {
result.catch(() => {
// Error handling is done in addHostSubmit
}).finally(() => {
hostModal.confirmLoading = false;
});
} else {
// If not async, reset loading after a short delay
setTimeout(() => {
hostModal.confirmLoading = false;
}, 100);
}
} catch (e) {
console.error("Error in hostModal.ok():", e);
hostModal.confirmLoading = false;
}
} else {
hostModal.confirmLoading = false;
}
},
show({ title = '', okText = '{{ i18n "sure" }}', host = null, confirm = () => {}, isEdit = false }) {
this.title = title;
this.okText = okText;
this.isEdit = isEdit;
this.confirm = confirm;
if (host) {
this.formData = {
name: host.name || '',
address: host.address || '',
port: host.port || 0,
protocol: host.protocol || 'tcp',
inboundIds: host.inboundIds ? [...host.inboundIds] : [],
enable: host.enable !== undefined ? host.enable : true
};
} else {
this.formData = {
name: '',
address: '',
port: 0,
protocol: 'tcp',
inboundIds: [],
enable: true
};
}
this.visible = true;
},
close() {
this.visible = false;
this.confirmLoading = false;
},
loading(loading = true) {
this.confirmLoading = loading;
}
};
const hostModalApp = new Vue({
delimiters: ['[[', ']]'],
el: '#host-modal',
data: {
hostModal: hostModal,
get themeSwitcher() {
return typeof themeSwitcher !== 'undefined' ? themeSwitcher : { currentTheme: 'light' };
},
get allInbounds() {
return typeof app !== 'undefined' && app.allInbounds ? app.allInbounds : [];
}
}
});
</script>
{{end}}

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;
@ -102,17 +132,14 @@
get isEdit() { get isEdit() {
return inModal.isEdit; return inModal.isEdit;
}, },
get client() {
return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
},
get datepicker() { get datepicker() {
return app.datepicker; return app.datepicker;
}, },
get delayedExpireDays() { get multiNodeMode() {
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0; return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false;
}, },
set delayedExpireDays(days) { get availableNodes() {
this.client.expiryTime = -86400000 * days; return app && app.availableNodes || [];
}, },
get externalProxy() { get externalProxy() {
return this.inbound.stream.externalProxy.length > 0; return this.inbound.stream.externalProxy.length > 0;

View file

@ -0,0 +1,228 @@
{{define "modals/nodeModal"}}
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600"
:confirm-loading="nodeModal.registering" :ok-button-props="{ disabled: nodeModal.registering }">
<div v-if="!nodeModal.registering && !nodeModal.showProgress">
<a-form layout="vertical">
<a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
<a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
<a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<!-- API key is now auto-generated during registration, no need for user input -->
</a-form>
</div>
<!-- Progress animation during registration -->
<div v-if="nodeModal.showProgress" style="padding: 20px 0; text-align: center;">
<a-steps :current="nodeModal.currentStep" direction="vertical" size="small">
<a-step title='{{ i18n "pages.nodes.connecting" }}' :status="nodeModal.steps.connecting">
<template slot="description">
<a-spin v-if="nodeModal.steps.connecting === 'process'" size="small" style="margin-right: 8px;"></a-spin>
<span v-if="nodeModal.steps.connecting === 'finish'">✓ {{ i18n "pages.nodes.connectionEstablished" }}</span>
<span v-if="nodeModal.steps.connecting === 'error'">✗ {{ i18n "pages.nodes.connectionError" }}</span>
</template>
</a-step>
<a-step title='{{ i18n "pages.nodes.generatingApiKey" }}' :status="nodeModal.steps.generating">
<template slot="description">
<a-spin v-if="nodeModal.steps.generating === 'process'" size="small" style="margin-right: 8px;"></a-spin>
<span v-if="nodeModal.steps.generating === 'finish'">✓ {{ i18n "pages.nodes.apiKeyGenerated" }}</span>
<span v-if="nodeModal.steps.generating === 'error'">✗ {{ i18n "pages.nodes.generationError" }}</span>
</template>
</a-step>
<a-step title='{{ i18n "pages.nodes.registeringNode" }}' :status="nodeModal.steps.registering">
<template slot="description">
<a-spin v-if="nodeModal.steps.registering === 'process'" size="small" style="margin-right: 8px;"></a-spin>
<span v-if="nodeModal.steps.registering === 'finish'">✓ {{ i18n "pages.nodes.nodeRegistered" }}</span>
<span v-if="nodeModal.steps.registering === 'error'">✗ {{ i18n "pages.nodes.registrationError" }}</span>
</template>
</a-step>
<a-step title='{{ i18n "pages.nodes.done" }}' :status="nodeModal.steps.completed">
<template slot="description">
<span v-if="nodeModal.steps.completed === 'finish'" style="color: #52c41a; font-weight: bold;">✓ {{ i18n "pages.nodes.nodeAddedSuccessfully" }}</span>
</template>
</a-step>
</a-steps>
</div>
</a-modal>
<script>
const nodeModal = window.nodeModal = {
visible: false,
title: '',
okText: 'OK',
registering: false,
showProgress: false,
currentStep: 0,
steps: {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
},
formData: {
name: '',
address: '',
port: 8080
// apiKey is now auto-generated during registration
},
ok() {
// Валидация полей - используем nodeModal напрямую для правильного контекста
if (!nodeModal.formData.name || !nodeModal.formData.name.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
}
return;
}
if (!nodeModal.formData.address || !nodeModal.formData.address.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
}
return;
}
// API key is now auto-generated during registration, no validation needed
// Если все поля заполнены, формируем полный адрес с портом
const dataToSend = { ...nodeModal.formData };
// Всегда добавляем порт к адресу
let fullAddress = dataToSend.address.trim();
const port = dataToSend.port && dataToSend.port > 0 ? dataToSend.port : 8080;
// Правильно добавляем порт к URL
// Парсим URL: http://192.168.0.7 -> http://192.168.0.7:8080
const urlMatch = fullAddress.match(/^(https?:\/\/)([^\/:]+)(\/.*)?$/);
if (urlMatch) {
const protocol = urlMatch[1]; // http:// или https://
const host = urlMatch[2]; // 192.168.0.7
const path = urlMatch[3] || ''; // /path или ''
fullAddress = `${protocol}${host}:${port}${path}`;
} else {
// Если не удалось распарсить, просто добавляем порт
fullAddress = `${fullAddress}:${port}`;
}
// Удаляем порт из данных, так как он теперь в адресе
delete dataToSend.port;
dataToSend.address = fullAddress;
// Если это режим редактирования, просто вызываем confirm
if (nodeModal.isEdit) {
if (nodeModal.confirm) {
nodeModal.confirm(dataToSend);
}
nodeModal.visible = false;
return;
}
// Для добавления новой ноды показываем прогресс регистрации
nodeModal.registering = true;
nodeModal.showProgress = true;
nodeModal.currentStep = 0;
// Сброс всех шагов
nodeModal.steps = {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
};
// Вызываем confirm с объединенным адресом (это запустит регистрацию)
if (nodeModal.confirm) {
nodeModal.confirm(dataToSend);
}
},
cancel() {
this.visible = false;
this.resetProgress();
},
show({ title = '', okText = 'OK', node = null, confirm = (data) => { }, isEdit = false }) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
this.isEdit = isEdit;
this.registering = false;
this.showProgress = false;
this.currentStep = 0;
this.steps = {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
};
if (node) {
// Извлекаем адрес и порт из полного URL
let address = node.address || '';
let port = 8080;
// Всегда извлекаем порт из адреса, если он там есть
if (address) {
const urlMatch = address.match(/^(https?:\/\/[^\/:]+)(:(\d+))?(\/.*)?$/);
if (urlMatch) {
// Убираем порт из адреса для отображения
const protocol = urlMatch[1].match(/^(https?:\/\/)/)[1];
const host = urlMatch[1].replace(/^https?:\/\//, '');
const path = urlMatch[4] || '';
address = `${protocol}${host}${path}`;
// Если порт был в адресе, извлекаем его
if (urlMatch[3]) {
port = parseInt(urlMatch[3], 10);
}
}
}
this.formData = {
name: node.name || '',
address: address,
port: port
// apiKey is not shown in edit mode (it's managed by the system)
};
} else {
this.formData = {
name: '',
address: '',
port: 8080
// apiKey is auto-generated during registration
};
}
this.visible = true;
},
close() {
this.visible = false;
this.resetProgress();
},
resetProgress() {
this.registering = false;
this.showProgress = false;
this.currentStep = 0;
this.steps = {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
};
}
};
const nodeModalVueInstance = new Vue({
delimiters: ['[[', ']]'],
el: '#node-modal',
data: {
nodeModal: nodeModal
}
});
</script>
{{end}}

View file

@ -21,7 +21,7 @@
</a-space> </a-space>
</template> </template>
<tr-qr-modal class="qr-modal"> <tr-qr-modal class="qr-modal">
<template v-if="app.subSettings?.enable && qrModal.subId"> <template v-if="app.subSettings && app.subSettings.enable && qrModal.client && qrModal.client.subId">
<tr-qr-box class="qr-box"> <tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}}</span></a-tag> <a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}}</span></a-tag>
<tr-qr-bg class="qr-bg-sub"> <tr-qr-bg class="qr-bg-sub">
@ -30,7 +30,7 @@
</tr-qr-bg-inner> </tr-qr-bg-inner>
</tr-qr-bg> </tr-qr-bg>
</tr-qr-box> </tr-qr-box>
<tr-qr-box class="qr-box" v-if="app.subSettings.subJsonEnable"> <tr-qr-box class="qr-box" v-if="app.subSettings && app.subSettings.subJsonEnable && qrModal.client && qrModal.client.subId">
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Json</span></a-tag> <a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Json</span></a-tag>
<tr-qr-bg class="qr-bg-sub"> <tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner"> <tr-qr-bg-inner class="qr-bg-sub-inner">
@ -110,7 +110,8 @@
this.dbInbound = dbInbound; this.dbInbound = dbInbound;
this.inbound = dbInbound.toInbound(); this.inbound = dbInbound.toInbound();
this.client = client; this.client = client;
this.subId = ''; // Set subId from client if available
this.subId = (client && client.subId) ? client.subId : '';
this.qrcodes = []; this.qrcodes = [];
// Reset the status fetched flag when showing the modal // Reset the status fetched flag when showing the modal
if (qrModalApp) qrModalApp.statusFetched = false; if (qrModalApp) qrModalApp.statusFetched = false;
@ -124,12 +125,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
}); });
}); });
} }
@ -231,9 +245,15 @@
}); });
}, },
genSubLink(subID) { genSubLink(subID) {
if (!app || !app.subSettings || !app.subSettings.subURI) {
return '';
}
return app.subSettings.subURI + subID; return app.subSettings.subURI + subID;
}, },
genSubJsonLink(subID) { genSubJsonLink(subID) {
if (!app || !app.subSettings || !app.subSettings.subJsonURI) {
return '';
}
return app.subSettings.subJsonURI + subID; return app.subSettings.subJsonURI + subID;
}, },
revertOverflow() { revertOverflow() {
@ -261,9 +281,15 @@
} }
if (qrModal.client && qrModal.client.subId) { if (qrModal.client && qrModal.client.subId) {
qrModal.subId = qrModal.client.subId; qrModal.subId = qrModal.client.subId;
this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId)); const subLink = this.genSubLink(qrModal.subId);
if (app.subSettings.subJsonEnable) { if (subLink) {
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId)); this.setQrCode("qrCode-sub", subLink);
}
if (app && app.subSettings && app.subSettings.subJsonEnable) {
const subJsonLink = this.genSubJsonLink(qrModal.subId);
if (subJsonLink) {
this.setQrCode("qrCode-subJson", subJsonLink);
}
} }
} }
qrModal.qrcodes.forEach((element, index) => { qrModal.qrcodes.forEach((element, index) => {

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

@ -0,0 +1,613 @@
{{ 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' }">
<transition name="list" appear>
<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;">
<a-button type="primary" icon="plus" @click="openAddNode">{{ i18n "pages.nodes.addNewNode" }}</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="responseTime" slot-scope="text, node">
<span v-if="node.responseTime && node.responseTime > 0" :style="{
color: node.responseTime < 100 ? '#52c41a' : node.responseTime < 300 ? '#faad14' : '#ff4d4f',
fontWeight: 'bold'
}">
[[ node.responseTime ]] ms
</span>
<span v-else style="color: #999;">-</span>
</template>
<template slot="inbounds" slot-scope="text, node">
<template v-if="node.inbounds && node.inbounds.length > 0">
<a-tag v-for="(inbound, index) in node.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
[[ 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>
</transition>
</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" .}}
{{template "modals/nodeModal"}}
<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.responseTime" }}',
align: 'center',
width: 100,
scopedSlots: { customRender: 'responseTime' },
}, {
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: '',
pollInterval: null,
},
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',
responseTime: node.responseTime || 0,
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.editNode(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;
}
},
openAddNode() {
if (typeof window.nodeModal !== 'undefined') {
window.nodeModal.show({
title: '{{ i18n "pages.nodes.addNewNode" }}',
okText: '{{ i18n "create" }}',
confirm: async (nodeData) => {
await this.submitNode(nodeData, false);
},
isEdit: false
});
} else {
console.error('[openAddNode] ERROR: nodeModal is not defined!');
}
},
editNode(node) {
if (typeof window.nodeModal !== 'undefined') {
// Load full node data including TLS settings
HttpUtil.get(`/panel/node/get/${node.id}`).then(msg => {
if (msg && msg.success && msg.obj) {
window.nodeModal.show({
title: '{{ i18n "pages.nodes.editNode" }}',
okText: '{{ i18n "update" }}',
node: msg.obj,
confirm: async (nodeData) => {
await this.submitNode(nodeData, true, node.id);
},
isEdit: true
});
} else {
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
}
}).catch(e => {
console.error("Failed to load node:", e);
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
});
} else {
console.error('[editNode] ERROR: nodeModal is not defined!');
}
},
async submitNode(nodeData, isEdit, nodeId = null) {
// Для редактирования используем обычный процесс
if (isEdit) {
try {
const url = `/panel/node/update/${nodeId}`;
const msg = await HttpUtil.post(url, nodeData);
if (msg && msg.success) {
app.$message.success('{{ i18n "pages.nodes.updateSuccess" }}');
await this.loadNodes();
if (window.nodeModal) {
window.nodeModal.close();
}
} else {
app.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.updateError" }}');
}
} catch (e) {
console.error('Failed to update node:', e);
app.$message.error('{{ i18n "pages.nodes.updateError" }}');
}
return;
}
// Для добавления новой ноды показываем прогресс регистрации
const modal = window.nodeModal;
if (!modal) {
app.$message.error('Modal not found');
return;
}
try {
// Шаг 1: Устанавливаю соединение
modal.currentStep = 0;
modal.steps.connecting = 'process';
// Проверяем доступность ноды через панель (избегаем CORS)
try {
const checkMsg = await HttpUtil.post('/panel/node/check-connection', {
address: nodeData.address
});
if (!checkMsg || !checkMsg.success) {
modal.steps.connecting = 'error';
app.$message.error(checkMsg?.msg || 'Нода недоступна. Проверьте адрес и порт.');
modal.registering = false;
return;
}
} catch (e) {
modal.steps.connecting = 'error';
app.$message.error('Нода недоступна. Проверьте адрес и порт.');
modal.registering = false;
return;
}
modal.steps.connecting = 'finish';
modal.currentStep = 1;
// Небольшая задержка для визуального эффекта
await new Promise(resolve => setTimeout(resolve, 300));
// Шаг 2: Генерирую API ключ
modal.steps.generating = 'process';
await new Promise(resolve => setTimeout(resolve, 500)); // Имитация генерации
modal.steps.generating = 'finish';
modal.currentStep = 2;
// Небольшая задержка для визуального эффекта
await new Promise(resolve => setTimeout(resolve, 300));
// Шаг 3: Регистрирую ноду
modal.steps.registering = 'process';
const url = '/panel/node/add';
const msg = await HttpUtil.post(url, nodeData);
if (msg && msg.success) {
modal.steps.registering = 'finish';
modal.currentStep = 3;
// Небольшая задержка для визуального эффекта
await new Promise(resolve => setTimeout(resolve, 300));
// Шаг 4: Готово
modal.steps.completed = 'finish';
// Задержка перед закрытием модалки
await new Promise(resolve => setTimeout(resolve, 800));
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
await this.loadNodes();
if (window.nodeModal) {
window.nodeModal.close();
}
} else {
modal.steps.registering = 'error';
app.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.addError" }}');
modal.registering = false;
}
} catch (e) {
console.error('Failed to add node:', e);
// Определяем на каком шаге произошла ошибка
if (modal.steps.connecting === 'process') {
modal.steps.connecting = 'error';
} else if (modal.steps.generating === 'process') {
modal.steps.generating = 'error';
} else if (modal.steps.registering === 'process') {
modal.steps.registering = 'error';
}
app.$message.error('{{ i18n "pages.nodes.addError" }}');
modal.registering = false;
}
},
startPolling() {
// Poll every 5 seconds as fallback
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
this.pollInterval = setInterval(() => {
this.loadNodes();
}, 5000);
}
},
beforeDestroy() {
// Clean up polling interval
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
},
async mounted() {
await this.loadNodes();
// Setup WebSocket for real-time updates
if (window.wsClient) {
window.wsClient.connect();
// Listen for nodes updates
window.wsClient.on('nodes', (payload) => {
if (payload && Array.isArray(payload)) {
this.nodes = payload.map(node => ({
id: node.id,
name: node.name || '',
address: node.address || '',
status: node.status || 'unknown',
responseTime: node.responseTime || 0,
inbounds: node.inbounds || []
}));
}
});
// Fallback to polling if WebSocket fails
window.wsClient.on('error', () => {
console.warn('WebSocket connection failed, falling back to polling');
this.startPolling();
});
window.wsClient.on('disconnected', () => {
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
console.warn('WebSocket reconnection failed, falling back to polling');
this.startPolling();
}
});
} else {
// Fallback to polling if WebSocket is not available
this.startPolling();
}
}
});
</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

@ -0,0 +1,155 @@
// Package job provides scheduled tasks for monitoring client HWIDs from access logs.
// NOTE: In client_header mode, this job does NOT generate HWIDs from logs.
// HWID registration happens explicitly via RegisterHWIDFromHeaders when subscription is requested.
package job
import (
"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/web/service"
"github.com/mhsanaei/3x-ui/v2/xray"
)
// CheckClientHWIDJob monitors client HWIDs from access logs and manages HWID tracking.
type CheckClientHWIDJob struct {
lastClear int64
}
var hwidJob *CheckClientHWIDJob
// NewCheckClientHWIDJob creates a new client HWID monitoring job instance.
func NewCheckClientHWIDJob() *CheckClientHWIDJob {
if hwidJob == nil {
hwidJob = new(CheckClientHWIDJob)
}
return hwidJob
}
// Run executes the HWID monitoring job.
func (j *CheckClientHWIDJob) Run() {
// Check if multi-node mode is enabled
settingService := service.SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err == nil && multiMode {
// In multi-node mode, HWID checking is handled by nodes
return
}
if j.lastClear == 0 {
j.lastClear = time.Now().Unix()
}
hwidTrackingActive := j.hasHWIDTracking()
if !hwidTrackingActive {
return
}
isAccessLogAvailable := j.checkAccessLogAvailable()
if !isAccessLogAvailable {
return
}
// Process access log to track HWIDs
j.processLogFile()
// Clear access log periodically (every hour)
if time.Now().Unix()-j.lastClear > 3600 {
j.clearAccessLog()
}
}
// hasHWIDTracking checks if HWID tracking is enabled globally and for any client.
func (j *CheckClientHWIDJob) hasHWIDTracking() bool {
// Check global HWID mode setting
settingService := service.SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting: %v", err)
return false
}
// If HWID tracking is disabled globally, skip
if hwidMode == "off" {
return false
}
// Check if any client has HWID tracking enabled
db := database.GetDB()
var clients []*model.ClientEntity
err = db.Where("hwid_enabled = ?", true).Find(&clients).Error
if err != nil {
return false
}
return len(clients) > 0
}
// checkAccessLogAvailable checks if access log is available.
func (j *CheckClientHWIDJob) checkAccessLogAvailable() bool {
accessLogPath, err := xray.GetAccessLogPath()
if err != nil {
return false
}
if accessLogPath == "none" || accessLogPath == "" {
return false
}
return true
}
// processLogFile processes the access log file to update last_seen_at and IP for existing HWIDs.
// NOTE: This job does NOT generate or create new HWID records.
// HWID registration must be done explicitly via RegisterHWIDFromHeaders when x-hwid header is provided.
// This job only updates existing HWID records with connection information from access logs.
func (j *CheckClientHWIDJob) processLogFile() {
// Check HWID mode - only run in legacy_fingerprint mode
settingService := service.SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting: %v", err)
return
}
// In client_header mode, this job should not process logs for HWID generation
// It may still update last_seen_at for existing HWIDs if needed
if hwidMode == "off" {
// HWID tracking disabled - skip processing
return
}
// In client_header mode, we don't generate HWIDs from logs
// Only update existing HWIDs if we can match them somehow
// For now, skip log processing in client_header mode
// (HWID registration happens via RegisterHWIDFromHeaders when subscription is requested)
if hwidMode == "client_header" {
// In client_header mode, HWID comes from headers, not logs
// This job should not process logs for HWID generation
// TODO: Could potentially update last_seen_at for existing HWIDs if we can match them,
// but without x-hwid header in logs, we can't reliably match
return
}
// Legacy fingerprint mode (deprecated)
// This mode may use fingerprint-based HWID generation from logs
if hwidMode == "legacy_fingerprint" {
// Legacy mode: may generate HWID from logs (deprecated behavior)
// This is kept for backward compatibility only
logger.Debug("Running in legacy_fingerprint mode (deprecated)")
// TODO: Implement legacy fingerprint logic if needed for backward compatibility
// For now, skip to avoid false positives
return
}
}
// clearAccessLog clears the access log file (similar to CheckClientIpJob).
func (j *CheckClientHWIDJob) clearAccessLog() {
// This is similar to CheckClientIpJob.clearAccessLog
// We can reuse the same logic or call it from there
// For now, we'll just update the last clear time
j.lastClear = time.Now().Unix()
}

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,89 @@
// Package job provides scheduled background jobs for the 3x-ui panel.
package job
import (
"sync"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
)
// CheckNodeHealthJob periodically checks the health of all nodes in multi-node mode.
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))
// Use a wait group to wait for all health checks to complete
var wg sync.WaitGroup
for _, node := range nodes {
n := node // Capture loop variable
wg.Add(1)
go func() {
defer wg.Done()
if err := j.nodeService.CheckNodeHealth(n); err != nil {
logger.Debugf("[Node: %s] Health check failed: %v", n.Name, err)
} else {
logger.Debugf("[Node: %s] Status: %s, ResponseTime: %d ms", n.Name, n.Status, n.ResponseTime)
}
}()
}
// Wait for all checks to complete, then broadcast update
go func() {
wg.Wait()
// Get updated nodes with response times
updatedNodes, err := j.nodeService.GetAllNodes()
if err != nil {
logger.Warningf("Failed to get nodes for WebSocket broadcast: %v", err)
return
}
// Enrich nodes with assigned inbounds information
type NodeWithInbounds struct {
*model.Node
Inbounds []*model.Inbound `json:"inbounds,omitempty"`
}
result := make([]NodeWithInbounds, 0, len(updatedNodes))
for _, node := range updatedNodes {
inbounds, _ := j.nodeService.GetInboundsForNode(node.Id)
result = append(result, NodeWithInbounds{
Node: node,
Inbounds: inbounds,
})
}
// Broadcast via WebSocket
websocket.BroadcastNodes(result)
}()
}

View file

@ -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")
}

132
web/middleware/cache.go Normal file
View file

@ -0,0 +1,132 @@
// Package middleware provides HTTP response caching middleware for the 3x-ui web panel.
package middleware
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v2/web/cache"
)
// CacheMiddleware creates a middleware that caches HTTP responses.
// It caches GET requests based on the full URL path and query parameters.
func CacheMiddleware(ttl time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Only cache GET requests
if c.Request.Method != "GET" {
c.Next()
return
}
// Generate cache key from request path and query
cacheKey := generateCacheKey(c.Request.URL.Path, c.Request.URL.RawQuery)
// Try to get from cache
var cachedResponse map[string]interface{}
err := cache.GetJSON(cacheKey, &cachedResponse)
if err == nil {
// Cache hit - return cached response
c.JSON(200, cachedResponse)
c.Abort()
return
}
// Cache miss - continue to handler and capture response
c.Next()
// Only cache successful responses (status 200)
if c.Writer.Status() == 200 {
// Try to capture the response body
// Note: This is a simplified version - in production you might want to use
// a response writer wrapper to capture the actual response body
// For now, we'll let the service layer handle caching
}
}
}
// CacheResponse caches a JSON response with the given key and TTL.
func CacheResponse(key string, data interface{}, ttl time.Duration) error {
return cache.SetJSON(key, data, ttl)
}
// GetCachedResponse retrieves a cached JSON response.
func GetCachedResponse(key string, dest interface{}) error {
return cache.GetJSON(key, dest)
}
// InvalidateCacheKey invalidates a specific cache key.
func InvalidateCacheKey(key string) error {
return cache.Delete(key)
}
// generateCacheKey creates a cache key from path and query string.
func generateCacheKey(path, query string) string {
key := fmt.Sprintf("http:%s", path)
if query != "" {
hash := sha256.Sum256([]byte(query))
key += ":" + hex.EncodeToString(hash[:])[:16]
}
return key
}
// UserCacheMiddleware creates a middleware that caches responses per user.
// It includes the user ID in the cache key to ensure user-specific caching.
func UserCacheMiddleware(ttl time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Only cache GET requests
if c.Request.Method != "GET" {
c.Next()
return
}
// Get user ID from session
userID := getUserIDFromContext(c)
if userID == 0 {
c.Next()
return
}
// Generate cache key with user ID
cacheKey := generateUserCacheKey(c.Request.URL.Path, c.Request.URL.RawQuery, userID)
// Try to get from cache
var cachedResponse map[string]interface{}
err := cache.GetJSON(cacheKey, &cachedResponse)
if err == nil {
// Cache hit - return cached response
c.JSON(200, cachedResponse)
c.Abort()
return
}
// Cache miss - continue to handler
c.Next()
}
}
// generateUserCacheKey creates a cache key with user ID.
func generateUserCacheKey(path, query string, userID int) string {
key := fmt.Sprintf("http:user:%d:%s", userID, path)
if query != "" {
hash := sha256.Sum256([]byte(query))
key += ":" + hex.EncodeToString(hash[:])[:16]
}
return key
}
// getUserIDFromContext extracts user ID from gin context.
// This is a helper function - you may need to adjust based on your session implementation.
func getUserIDFromContext(c *gin.Context) int {
// Try to get from session
if user, exists := c.Get("user"); exists {
if userMap, ok := user.(map[string]interface{}); ok {
if id, ok := userMap["id"].(int); ok {
return id
}
}
}
return 0
}

1282
web/service/client.go Normal file

File diff suppressed because it is too large Load diff

342
web/service/client_hwid.go Normal file
View file

@ -0,0 +1,342 @@
// Package service provides HWID (Hardware ID) management for clients.
// HWID is provided explicitly by client applications via HTTP headers (x-hwid).
// Server MUST NOT generate or derive HWID from IP, User-Agent, or access logs.
package service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"gorm.io/gorm"
)
// ClientHWIDService provides business logic for managing client HWIDs.
type ClientHWIDService struct{}
// GetHWIDsForClient retrieves all HWIDs associated with a client.
func (s *ClientHWIDService) GetHWIDsForClient(clientId int) ([]*model.ClientHWID, error) {
db := database.GetDB()
var hwids []*model.ClientHWID
err := db.Where("client_id = ?", clientId).Order("last_seen_at DESC").Find(&hwids).Error
return hwids, err
}
// AddHWIDForClient adds a new HWID for a client with device metadata.
// HWID must be provided explicitly (not generated).
// If the client has HWID restrictions enabled, checks if the limit is exceeded.
func (s *ClientHWIDService) AddHWIDForClient(clientId int, hwid string, deviceOS string, deviceModel string, osVersion string, ipAddress string, userAgent string) (*model.ClientHWID, error) {
// Normalize HWID (trim, but preserve case - HWID is opaque identifier from client)
hwid = strings.TrimSpace(hwid)
if hwid == "" {
return nil, fmt.Errorf("HWID cannot be empty")
}
// Get client to check restrictions
clientService := ClientService{}
client, err := clientService.GetClient(clientId)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}
if client == nil {
return nil, fmt.Errorf("client not found")
}
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Check if HWID already exists for this client
var existingHWID model.ClientHWID
err = tx.Where("client_id = ? AND hwid = ?", clientId, hwid).First(&existingHWID).Error
if err == nil {
// HWID exists - update last seen and IP
now := time.Now().Unix()
updates := map[string]interface{}{
"last_seen_at": now,
"ip_address": ipAddress,
}
if userAgent != "" {
updates["user_agent"] = userAgent
}
// Update device metadata if provided
if deviceOS != "" {
updates["device_os"] = deviceOS
}
if deviceModel != "" {
updates["device_model"] = deviceModel
}
if osVersion != "" {
updates["os_version"] = osVersion
}
existingHWID.IsActive = true
err = tx.Model(&existingHWID).Updates(updates).Error
if err != nil {
return nil, err
}
// Reload to get updated fields
tx.First(&existingHWID, existingHWID.Id)
return &existingHWID, nil
} else if err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("failed to check existing HWID: %w", err)
}
// HWID doesn't exist - check if we can add it
var activeHWIDCount int64
if client.HWIDEnabled {
// Count active HWIDs for this client
err = tx.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
if err != nil {
return nil, fmt.Errorf("failed to count active HWIDs: %w", err)
}
// Check limit (0 means unlimited)
if client.MaxHWID > 0 && int(activeHWIDCount) >= client.MaxHWID {
return nil, fmt.Errorf("HWID limit exceeded: max %d devices allowed, current: %d", client.MaxHWID, activeHWIDCount)
}
} else {
// Count all HWIDs for device naming even if restriction is disabled
err = tx.Model(&model.ClientHWID{}).Where("client_id = ?", clientId).Count(&activeHWIDCount).Error
if err != nil {
return nil, fmt.Errorf("failed to count HWIDs: %w", err)
}
}
// Create new HWID record
now := time.Now().Unix()
newHWID := &model.ClientHWID{
ClientId: clientId,
HWID: hwid,
DeviceOS: deviceOS,
DeviceModel: deviceModel,
OSVersion: osVersion,
IPAddress: ipAddress,
FirstSeenIP: ipAddress,
UserAgent: userAgent,
IsActive: true,
FirstSeenAt: now,
LastSeenAt: now,
DeviceName: fmt.Sprintf("Device %d", activeHWIDCount+1), // Legacy field, deprecated
}
err = tx.Create(newHWID).Error
if err != nil {
logger.Errorf("Failed to create HWID record in database: %v", err)
return nil, fmt.Errorf("failed to create HWID: %w", err)
}
logger.Debugf("Successfully created HWID record: clientId=%d, hwid=%s, hwidId=%d", clientId, hwid, newHWID.Id)
return newHWID, nil
}
// RemoveHWID removes a HWID from a client.
func (s *ClientHWIDService) RemoveHWID(hwidId int) error {
db := database.GetDB()
return db.Delete(&model.ClientHWID{}, hwidId).Error
}
// DeactivateHWID deactivates a HWID (marks as inactive instead of deleting).
func (s *ClientHWIDService) DeactivateHWID(hwidId int) error {
db := database.GetDB()
return db.Model(&model.ClientHWID{}).Where("id = ?", hwidId).Update("is_active", false).Error
}
// CheckHWIDAllowed checks if a HWID is allowed for a client.
// Returns true if HWID restriction is disabled, or if HWID is in the allowed list.
// NOTE: This method does NOT auto-register HWID. Use RegisterHWIDFromHeaders for registration.
// Behavior depends on hwidMode setting:
// - "off": Always returns true (HWID tracking disabled)
// - "client_header": Requires explicit HWID registration, checks against registered devices
// - "legacy_fingerprint": Legacy mode (deprecated)
func (s *ClientHWIDService) CheckHWIDAllowed(clientId int, hwid string) (bool, error) {
// Check HWID mode setting
settingService := SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting, defaulting to client_header: %v", err)
hwidMode = "client_header"
}
// If HWID tracking is disabled globally, allow all
if hwidMode == "off" {
return true, nil
}
// Normalize HWID (trim, but preserve case - HWID is opaque identifier from client)
hwid = strings.TrimSpace(hwid)
if hwid == "" {
// In client_header mode, empty HWID means "unknown device" - don't count, but allow
if hwidMode == "client_header" {
return true, nil // Allow but don't count as registered device
}
return false, fmt.Errorf("HWID cannot be empty")
}
// Get client
clientService := ClientService{}
client, err := clientService.GetClient(clientId)
if err != nil {
return false, fmt.Errorf("failed to get client: %w", err)
}
if client == nil {
return false, fmt.Errorf("client not found")
}
// If HWID restriction is disabled for this client, allow all
if !client.HWIDEnabled {
return true, nil
}
// In client_header mode, HWID must be explicitly registered
if hwidMode == "client_header" {
// Check if HWID exists and is active
db := database.GetDB()
var hwidRecord model.ClientHWID
err = db.Where("client_id = ? AND hwid = ? AND is_active = ?", clientId, hwid, true).First(&hwidRecord).Error
if err == nil {
// HWID exists and is active - update last seen
db.Model(&hwidRecord).Update("last_seen_at", time.Now().Unix())
return true, nil
} else if err == gorm.ErrRecordNotFound {
// HWID not found - check if we're under limit (allows registration)
var activeHWIDCount int64
err = db.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
if err != nil {
return false, fmt.Errorf("failed to count active HWIDs: %w", err)
}
// If under limit, allow (registration can happen via RegisterHWIDFromHeaders)
if client.MaxHWID == 0 || int(activeHWIDCount) < client.MaxHWID {
return true, nil
}
// Limit reached, HWID not registered
return false, fmt.Errorf("HWID limit exceeded: max %d devices allowed, current: %d", client.MaxHWID, activeHWIDCount)
}
return false, fmt.Errorf("failed to check HWID: %w", err)
}
// Legacy fingerprint mode (deprecated) - kept for backward compatibility
// This mode may use fingerprint-based HWID generation (not recommended)
if hwidMode == "legacy_fingerprint" {
// Check if HWID exists and is active
db := database.GetDB()
var hwidRecord model.ClientHWID
err = db.Where("client_id = ? AND hwid = ? AND is_active = ?", clientId, hwid, true).First(&hwidRecord).Error
if err == nil {
// HWID exists and is active - update last seen
db.Model(&hwidRecord).Update("last_seen_at", time.Now().Unix())
return true, nil
} else if err == gorm.ErrRecordNotFound {
// HWID not found - check limit
var activeHWIDCount int64
err = db.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
if err != nil {
return false, fmt.Errorf("failed to count active HWIDs: %w", err)
}
// If under limit, allow (legacy mode may auto-register via job)
if client.MaxHWID == 0 || int(activeHWIDCount) < client.MaxHWID {
return true, nil
}
// Limit reached, HWID not in list
return false, nil
}
return false, fmt.Errorf("failed to check HWID: %w", err)
}
// Unknown mode - default to allowing (fail open)
logger.Warningf("Unknown hwidMode: %s, allowing request", hwidMode)
return true, nil
}
// RegisterHWIDFromHeaders registers a HWID from HTTP headers provided by client application.
// This is the primary method for HWID registration in client_header mode.
// Headers:
// - x-hwid (required): Hardware ID provided by client
// - x-device-os (optional): Device operating system
// - x-device-model (optional): Device model
// - x-ver-os (optional): OS version
// - user-agent (optional): User agent string
func (s *ClientHWIDService) RegisterHWIDFromHeaders(clientId int, hwid string, deviceOS string, deviceModel string, osVersion string, ipAddress string, userAgent string) (*model.ClientHWID, error) {
// HWID must be provided explicitly
hwid = strings.TrimSpace(hwid)
if hwid == "" {
return nil, fmt.Errorf("HWID is required (x-hwid header missing)")
}
// Get client to check restrictions
clientService := ClientService{}
client, err := clientService.GetClient(clientId)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}
if client == nil {
return nil, fmt.Errorf("client not found")
}
// Check HWID mode setting
settingService := SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting, defaulting to client_header: %v", err)
hwidMode = "client_header"
}
// In client_header mode, HWID must be provided explicitly (which it is, since we're here)
// In legacy_fingerprint mode, this method should not be called (use legacy methods)
if hwidMode == "off" {
// HWID tracking disabled - allow but don't register
return nil, nil
}
// Register or update HWID
logger.Debugf("RegisterHWIDFromHeaders: calling AddHWIDForClient for clientId=%d, hwid=%s", clientId, hwid)
return s.AddHWIDForClient(clientId, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent)
}
// UpdateHWIDLastSeen updates the last seen timestamp and IP address for a HWID.
func (s *ClientHWIDService) UpdateHWIDLastSeen(clientId int, hwid string, ipAddress string) error {
hwid = strings.TrimSpace(hwid) // Preserve case - HWID is opaque identifier
if hwid == "" {
return fmt.Errorf("HWID cannot be empty")
}
db := database.GetDB()
return db.Model(&model.ClientHWID{}).
Where("client_id = ? AND hwid = ?", clientId, hwid).
Updates(map[string]interface{}{
"last_seen_at": time.Now().Unix(),
"ip_address": ipAddress,
}).Error
}
// GenerateFingerprintHWID generates a fingerprint-based HWID from connection parameters.
// DEPRECATED: This method is only for legacy_fingerprint mode (backward compatibility).
// In client_header mode, HWID must be provided explicitly by client via x-hwid header.
// Do NOT use this method for new implementations.
func (s *ClientHWIDService) GenerateFingerprintHWID(email string, ipAddress string, userAgent string) string {
// DEPRECATED: This method should only be used in legacy_fingerprint mode
// Combine parameters to create a fingerprint
fingerprint := fmt.Sprintf("%s|%s|%s", email, ipAddress, userAgent)
// Hash the fingerprint to create a stable HWID
// NOTE: This approach is deprecated and may cause false positives
// when IP addresses change or clients reconnect from different networks
hash := sha256.Sum256([]byte(fingerprint))
return hex.EncodeToString(hash[:])[:32] // Use first 32 chars of hash
}

View file

@ -0,0 +1,320 @@
// Package service provides Client traffic management service.
package service
import (
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm"
)
// AddClientTraffic updates client traffic statistics and returns clients that need to be disabled.
// This method handles traffic tracking for clients in the new architecture (ClientEntity).
// After updating client traffic, it synchronizes inbound traffic as the sum of all its clients' traffic.
func (s *ClientService) AddClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic, inboundService *InboundService) (map[string]string, map[int]bool, error) {
clientsToDisable := make(map[string]string) // map[email]tag
affectedInboundIds := make(map[int]bool) // Track affected inbounds for traffic sync
if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return clientsToDisable, affectedInboundIds, nil
}
onlineClients := make([]string, 0)
// Group traffic by email (aggregate traffic from all inbounds for each client)
emailTrafficMap := make(map[string]struct {
Up int64
Down int64
InboundIds []int
})
for _, traffic := range traffics {
email := strings.ToLower(traffic.Email)
existing := emailTrafficMap[email]
existing.Up += traffic.Up
existing.Down += traffic.Down
// Track all inbound IDs for this email
if traffic.InboundId > 0 {
found := false
for _, id := range existing.InboundIds {
if id == traffic.InboundId {
found = true
break
}
}
if !found {
existing.InboundIds = append(existing.InboundIds, traffic.InboundId)
affectedInboundIds[traffic.InboundId] = true
}
}
emailTrafficMap[email] = existing
}
// Get all unique emails
emails := make([]string, 0, len(emailTrafficMap))
for email := range emailTrafficMap {
emails = append(emails, email)
}
if len(emails) == 0 {
return clientsToDisable, affectedInboundIds, nil
}
// Load ClientEntity records for these emails
var clientEntities []*model.ClientEntity
err := tx.Model(&model.ClientEntity{}).Where("LOWER(email) IN (?)", emails).Find(&clientEntities).Error
if err != nil {
return nil, nil, err
}
// Get inbound tags for clients that need to be disabled
inboundIdMap := make(map[int]string) // map[inboundId]tag
if len(affectedInboundIds) > 0 {
inboundIdList := make([]int, 0, len(affectedInboundIds))
for id := range affectedInboundIds {
inboundIdList = append(inboundIdList, id)
}
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIdList).Find(&inbounds).Error
if err == nil {
for _, inbound := range inbounds {
inboundIdMap[inbound.Id] = inbound.Tag
}
}
}
now := time.Now().Unix() * 1000
// Update traffic for each client
for _, client := range clientEntities {
email := strings.ToLower(client.Email)
trafficData, ok := emailTrafficMap[email]
if !ok {
continue
}
// Check limits BEFORE adding traffic
currentUsed := client.Up + client.Down
newUp := trafficData.Up
newDown := trafficData.Down
newTotal := newUp + newDown
// Check if time is already expired
timeExpired := client.ExpiryTime > 0 && client.ExpiryTime <= now
// Check if adding this traffic would exceed the limit
trafficLimit := int64(client.TotalGB * 1024 * 1024 * 1024)
if client.TotalGB > 0 && trafficLimit > 0 {
remaining := trafficLimit - currentUsed
if remaining <= 0 {
// Already exceeded, don't add any traffic
newUp = 0
newDown = 0
newTotal = 0
} else if newTotal > remaining {
// Would exceed, add only up to the limit
allowedTraffic := remaining
// Proportionally distribute allowed traffic between up and down
if newTotal > 0 {
ratio := float64(allowedTraffic) / float64(newTotal)
newUp = int64(float64(newUp) * ratio)
newDown = int64(float64(newDown) * ratio)
newTotal = allowedTraffic
} else {
newUp = 0
newDown = 0
newTotal = 0
}
}
}
// Add traffic (may be reduced if limit would be exceeded)
// Note: ClientTraffic.Up = uplink (server→client) = Download for client
// ClientTraffic.Down = downlink (client→server) = Upload for client
// So we swap them when saving to ClientEntity to match client perspective
client.Up += newDown // Upload (client→server) goes to Up
client.Down += newUp // Download (server→client) goes to Down
client.AllTime += newTotal
// Check final state after adding traffic
finalUsed := client.Up + client.Down
finalTrafficExceeded := client.TotalGB > 0 && finalUsed >= trafficLimit
// Mark client with expired status if limit exceeded or time expired
if (finalTrafficExceeded || timeExpired) && client.Enable {
// Update status if not already set or if reason changed
shouldUpdateStatus := false
if finalTrafficExceeded && client.Status != "expired_traffic" {
client.Status = "expired_traffic"
shouldUpdateStatus = true
} else if timeExpired && client.Status != "expired_time" {
client.Status = "expired_time"
shouldUpdateStatus = true
}
// Only add to disable list if status was just set (not already expired)
// This prevents repeated attempts to remove already-removed clients
if shouldUpdateStatus {
// Mark for removal from Xray API - get all inbound IDs for this client
clientInboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil && len(clientInboundIds) > 0 {
// Try to find tag from inboundIdMap first (from traffic data)
found := false
for _, inboundId := range clientInboundIds {
if tag, ok := inboundIdMap[inboundId]; ok {
clientsToDisable[client.Email] = tag
found = true
break
}
}
// If not found in map, query database for tag
if !found {
var inbound model.Inbound
if err := tx.Model(&model.Inbound{}).Where("id = ?", clientInboundIds[0]).First(&inbound).Error; err == nil {
clientsToDisable[client.Email] = inbound.Tag
}
}
}
logger.Infof("Client %s marked with status %s: trafficExceeded=%v, timeExpired=%v, currentUsed=%d, newTraffic=%d, finalUsed=%d, total=%d",
client.Email, client.Status, finalTrafficExceeded, timeExpired, currentUsed, newTotal, finalUsed, trafficLimit)
}
}
// Add user in onlineUsers array on traffic (only if not disabled)
if newTotal > 0 && client.Enable {
onlineClients = append(onlineClients, client.Email)
client.LastOnline = time.Now().UnixMilli()
}
}
// Set onlineUsers
if p != nil {
p.SetOnlineClients(onlineClients)
}
// Save client entities with retry logic for database lock errors
maxRetries := 3
baseDelay := 10 * time.Millisecond
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
delay := baseDelay * time.Duration(1<<uint(attempt-1))
logger.Debugf("Retrying Save client entities (attempt %d/%d) after %v", attempt+1, maxRetries, delay)
time.Sleep(delay)
}
err = tx.Save(clientEntities).Error
if err == nil {
break
}
// Check if error is "database is locked"
errStr := err.Error()
if strings.Contains(errStr, "database is locked") || strings.Contains(errStr, "locked") {
if attempt < maxRetries-1 {
logger.Debugf("Database locked when saving client entities, will retry: %v", err)
continue
}
// Last attempt failed
logger.Warningf("Failed to save client entities after %d retries: %v", maxRetries, err)
return nil, nil, err
}
// For other errors, don't retry
logger.Warning("AddClientTraffic update data ", err)
return nil, nil, err
}
// Synchronize inbound traffic as sum of all its clients' traffic
// IMPORTANT: Sync ALL inbounds, not just affected ones, to ensure accurate totals
if inboundService != nil {
// Get all inbounds to sync their traffic
allInbounds, err := inboundService.GetAllInbounds()
if err == nil {
allInboundIds := make(map[int]bool)
for _, inbound := range allInbounds {
allInboundIds[inbound.Id] = true
}
err = s.syncInboundTrafficFromClients(tx, allInboundIds, inboundService)
if err != nil {
logger.Warningf("Failed to sync inbound traffic from clients: %v", err)
// Don't fail the whole operation, but log the warning
}
} else {
logger.Warningf("Failed to get all inbounds for traffic sync: %v", err)
// Fallback: sync only affected inbounds
err = s.syncInboundTrafficFromClients(tx, affectedInboundIds, inboundService)
if err != nil {
logger.Warningf("Failed to sync affected inbound traffic: %v", err)
}
}
}
return clientsToDisable, affectedInboundIds, nil
}
// syncInboundTrafficFromClients synchronizes inbound traffic as the sum of all its clients' traffic.
// This ensures that inbound traffic always equals the sum of all its clients' traffic.
// Traffic is now stored in ClientEntity, so we sum traffic from all enabled clients assigned to each inbound.
func (s *ClientService) syncInboundTrafficFromClients(tx *gorm.DB, inboundIds map[int]bool, inboundService *InboundService) error {
if len(inboundIds) == 0 {
return nil
}
inboundIdList := make([]int, 0, len(inboundIds))
for id := range inboundIds {
inboundIdList = append(inboundIdList, id)
}
// For each inbound, get all its clients and sum their traffic
for _, inboundId := range inboundIdList {
// Get all clients assigned to this inbound
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
continue
}
// Sum traffic from ALL clients (both enabled and disabled) for inbound statistics
// This ensures inbound traffic reflects total usage, not just active clients
var totalUp int64
var totalDown int64
var totalAllTime int64
enabledClientCount := 0
totalClientCount := len(clientEntities)
for _, client := range clientEntities {
// Sum traffic from all clients (enabled and disabled) for statistics
totalUp += client.Up
totalDown += client.Down
totalAllTime += client.AllTime
if client.Enable {
enabledClientCount++
}
}
// Update inbound traffic
err = tx.Model(&model.Inbound{}).Where("id = ?", inboundId).
Updates(map[string]any{
"up": totalUp,
"down": totalDown,
"all_time": totalAllTime,
}).Error
if err != nil {
logger.Warningf("Failed to sync inbound %d traffic: %v", inboundId, err)
continue
}
logger.Debugf("Synced inbound %d traffic: up=%d, down=%d, all_time=%d (from %d total clients, %d enabled)",
inboundId, totalUp, totalDown, totalAllTime, totalClientCount, enabledClientCount)
}
return nil
}

254
web/service/host.go Normal file
View file

@ -0,0 +1,254 @@
// Package service provides Host management service for multi-node mode.
package service
import (
"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/util/common"
"gorm.io/gorm"
)
// HostService provides business logic for managing hosts.
type HostService struct{}
// GetHosts retrieves all hosts for a specific user.
func (s *HostService) GetHosts(userId int) ([]*model.Host, error) {
db := database.GetDB()
var hosts []*model.Host
err := db.Where("user_id = ?", userId).Find(&hosts).Error
if err != nil {
return nil, err
}
// Load inbound assignments for each host
for _, host := range hosts {
inboundIds, err := s.GetInboundIdsForHost(host.Id)
if err == nil {
host.InboundIds = inboundIds
}
}
return hosts, nil
}
// GetHost retrieves a host by ID.
func (s *HostService) GetHost(id int) (*model.Host, error) {
db := database.GetDB()
var host model.Host
err := db.First(&host, id).Error
if err != nil {
return nil, err
}
// Load inbound assignments
inboundIds, err := s.GetInboundIdsForHost(host.Id)
if err == nil {
host.InboundIds = inboundIds
}
return &host, nil
}
// GetInboundIdsForHost retrieves all inbound IDs assigned to a host.
func (s *HostService) GetInboundIdsForHost(hostId int) ([]int, error) {
db := database.GetDB()
var mappings []model.HostInboundMapping
err := db.Where("host_id = ?", hostId).Find(&mappings).Error
if err != nil {
return nil, err
}
inboundIds := make([]int, len(mappings))
for i, mapping := range mappings {
inboundIds[i] = mapping.InboundId
}
return inboundIds, nil
}
// GetHostForInbound retrieves the host assigned to an inbound (if any).
// Returns the first enabled host if multiple hosts are assigned.
func (s *HostService) GetHostForInbound(inboundId int) (*model.Host, error) {
db := database.GetDB()
var mapping model.HostInboundMapping
err := db.Where("inbound_id = ?", inboundId).First(&mapping).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil // No host assigned
}
return nil, err
}
var host model.Host
err = db.Where("id = ? AND enable = ?", mapping.HostId, true).First(&host).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil // Host disabled or not found
}
return nil, err
}
return &host, nil
}
// AddHost creates a new host.
func (s *HostService) AddHost(userId int, host *model.Host) error {
host.UserId = userId
// Set timestamps
now := time.Now().Unix()
if host.CreatedAt == 0 {
host.CreatedAt = now
}
host.UpdatedAt = now
db := database.GetDB()
tx := db.Begin()
var err error
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
err = tx.Create(host).Error
if err != nil {
return err
}
// Assign to inbounds if provided
if len(host.InboundIds) > 0 {
err = s.AssignHostToInbounds(tx, host.Id, host.InboundIds)
if err != nil {
return err
}
}
return nil
}
// UpdateHost updates an existing host.
func (s *HostService) UpdateHost(userId int, host *model.Host) error {
// Check if host exists and belongs to user
existing, err := s.GetHost(host.Id)
if err != nil {
return err
}
if existing.UserId != userId {
return common.NewError("Host not found or access denied")
}
// Update timestamp
host.UpdatedAt = time.Now().Unix()
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Update only provided fields
updates := make(map[string]interface{})
if host.Name != "" {
updates["name"] = host.Name
}
if host.Address != "" {
updates["address"] = host.Address
}
if host.Port > 0 {
updates["port"] = host.Port
}
if host.Protocol != "" {
updates["protocol"] = host.Protocol
}
if host.Remark != "" {
updates["remark"] = host.Remark
}
updates["enable"] = host.Enable
updates["updated_at"] = host.UpdatedAt
err = tx.Model(&model.Host{}).Where("id = ? AND user_id = ?", host.Id, userId).Updates(updates).Error
if err != nil {
return err
}
// Update inbound assignments if provided
if host.InboundIds != nil {
// Remove existing assignments
err = tx.Where("host_id = ?", host.Id).Delete(&model.HostInboundMapping{}).Error
if err != nil {
return err
}
// Add new assignments
if len(host.InboundIds) > 0 {
err = s.AssignHostToInbounds(tx, host.Id, host.InboundIds)
if err != nil {
return err
}
}
}
return nil
}
// DeleteHost deletes a host by ID.
func (s *HostService) DeleteHost(userId int, id int) error {
// Check if host exists and belongs to user
existing, err := s.GetHost(id)
if err != nil {
return err
}
if existing.UserId != userId {
return common.NewError("Host not found or access denied")
}
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Delete inbound mappings
err = tx.Where("host_id = ?", id).Delete(&model.HostInboundMapping{}).Error
if err != nil {
return err
}
// Delete host
err = tx.Where("id = ? AND user_id = ?", id, userId).Delete(&model.Host{}).Error
if err != nil {
return err
}
return nil
}
// AssignHostToInbounds assigns a host to multiple inbounds.
func (s *HostService) AssignHostToInbounds(tx *gorm.DB, hostId int, inboundIds []int) error {
for _, inboundId := range inboundIds {
mapping := &model.HostInboundMapping{
HostId: hostId,
InboundId: inboundId,
}
err := tx.Create(mapping).Error
if err != nil {
logger.Warningf("Failed to assign host %d to inbound %d: %v", hostId, inboundId, err)
// Continue with other assignments
}
}
return nil
}

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,879 @@
// Package service provides Node management service for multi-node architecture.
package service
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"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/util/random"
"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
}
// RegisterNode registers a node by sending it an API key generated by the panel.
// This method generates a unique API key, sends it to the node, and returns the key.
func (s *NodeService) RegisterNode(node *model.Node) (string, error) {
// Generate a unique API key (32 characters, alphanumeric)
apiKey := random.Seq(32)
// Determine panel URL to send to node
settingService := SettingService{}
protocol := "http"
if certFile, _ := settingService.GetCertFile(); certFile != "" {
protocol = "https"
}
listenIP, _ := settingService.GetListen()
listenPort, _ := settingService.GetPort()
basePath, _ := settingService.GetBasePath()
panelURL := fmt.Sprintf("%s://%s:%d%s", protocol, listenIP, listenPort, basePath)
// Prepare registration request
registerData := map[string]interface{}{
"apiKey": apiKey,
"panelUrl": panelURL,
"nodeAddress": node.Address,
}
// Send registration request to node
client, err := s.createHTTPClient(node, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create HTTP client: %w", err)
}
registerURL := fmt.Sprintf("%s/api/v1/register", node.Address)
jsonData, err := json.Marshal(registerData)
if err != nil {
return "", fmt.Errorf("failed to marshal registration data: %w", err)
}
req, err := http.NewRequest("POST", registerURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create registration request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to register node: %w (check if node is accessible at %s)", err, node.Address)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("node registration failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse response to verify registration
var registerResp map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
return "", fmt.Errorf("failed to parse registration response: %w", err)
}
logger.Infof("[Node: %s] Successfully registered node with API key", node.Name)
return apiKey, nil
}
// UpdateNode updates an existing node.
// Only updates fields that are provided (non-empty for strings, non-zero for integers).
func (s *NodeService) UpdateNode(node *model.Node) error {
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 TLS settings if provided
updates["use_tls"] = node.UseTLS
if node.CertPath != "" {
updates["cert_path"] = node.CertPath
}
if node.KeyPath != "" {
updates["key_path"] = node.KeyPath
}
updates["insecure_tls"] = node.InsecureTLS
// Update status, response_time, and last_check if provided (these are usually set by health checks, not user edits)
if node.Status != "" && node.Status != existingNode.Status {
updates["status"] = node.Status
}
if node.ResponseTime > 0 && node.ResponseTime != existingNode.ResponseTime {
updates["response_time"] = node.ResponseTime
} else if node.ResponseTime == 0 && existingNode.ResponseTime > 0 {
// Allow resetting to 0 (e.g., on error)
updates["response_time"] = 0
}
if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck {
updates["last_check"] = node.LastCheck
}
// 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 and response time.
func (s *NodeService) CheckNodeHealth(node *model.Node) error {
status, responseTime, err := s.CheckNodeStatus(node)
if err != nil {
node.Status = "error"
node.ResponseTime = 0 // Set to 0 on error
node.LastCheck = time.Now().Unix()
if updateErr := s.UpdateNode(node); updateErr != nil {
logger.Errorf("[Node: %s] Failed to update node status: %v", node.Name, updateErr)
}
return err
}
node.Status = status
node.ResponseTime = responseTime
node.LastCheck = time.Now().Unix()
logger.Debugf("[Node: %s] Health check: status=%s, responseTime=%d ms", node.Name, status, responseTime)
if updateErr := s.UpdateNode(node); updateErr != nil {
logger.Errorf("[Node: %s] Failed to update node with response time: %v", node.Name, updateErr)
return updateErr
}
return nil
}
// createHTTPClient creates an HTTP client configured for the node's TLS settings.
func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration) (*http.Client, error) {
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: node.InsecureTLS,
},
}
// If custom certificates are provided, load them
if node.UseTLS && node.CertPath != "" {
// Load custom CA certificate
cert, err := os.ReadFile(node.CertPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate file: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(cert) {
return nil, fmt.Errorf("failed to parse certificate")
}
transport.TLSClientConfig.RootCAs = caCertPool
transport.TLSClientConfig.InsecureSkipVerify = false // Use custom CA
}
// If custom key is provided, load client certificate
if node.UseTLS && node.KeyPath != "" && node.CertPath != "" {
// Load client certificate (cert + key)
clientCert, err := tls.LoadX509KeyPair(node.CertPath, node.KeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
transport.TLSClientConfig.Certificates = []tls.Certificate{clientCert}
}
return &http.Client{
Timeout: timeout,
Transport: transport,
}, nil
}
// CheckNodeStatus performs a health check on a given node and measures response time.
// Returns status string and response time in milliseconds.
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, int64, error) {
client, err := s.createHTTPClient(node, 5*time.Second)
if err != nil {
return "error", 0, err
}
url := fmt.Sprintf("%s/health", node.Address)
// Measure response time
startTime := time.Now()
resp, err := client.Get(url)
responseTime := time.Since(startTime).Milliseconds()
if err != nil {
return "offline", 0, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return "online", responseTime, nil
}
return "error", 0, 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, err := s.createHTTPClient(node, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
url := fmt.Sprintf("%s/api/v1/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("[Node: %s] Skipping stats collection: %v", result.node.Name, result.err)
} else {
// Unexpected errors should be logged as warning
logger.Warningf("[Node: %s] Failed to get stats: %v", result.node.Name, 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, err := s.createHTTPClient(node, 30*time.Second)
if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
}
// Get panel URL to send to node
panelURL := s.getPanelURL()
// Prepare request body with config and panel URL
requestBody := map[string]interface{}{
"config": json.RawMessage(xrayConfig),
}
if panelURL != "" {
requestBody["panelUrl"] = panelURL
}
requestJSON, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON))
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
}
// getPanelURL constructs the panel URL from settings.
func (s *NodeService) getPanelURL() string {
settingService := SettingService{}
// Get panel settings
webListen, _ := settingService.GetListen()
webPort, _ := settingService.GetPort()
webDomain, _ := settingService.GetWebDomain()
webCertFile, _ := settingService.GetCertFile()
webKeyFile, _ := settingService.GetKeyFile()
webBasePath, _ := settingService.GetBasePath()
// Determine protocol
protocol := "http"
if webCertFile != "" || webKeyFile != "" {
protocol = "https"
}
// Determine host
host := webDomain
if host == "" {
host = webListen
if host == "" {
// If no listen IP specified, use localhost (node should be able to reach panel)
host = "127.0.0.1"
}
}
// Construct URL
url := fmt.Sprintf("%s://%s", protocol, host)
if webPort > 0 && webPort != 80 && webPort != 443 {
url += fmt.Sprintf(":%d", webPort)
}
// Add base path (remove trailing slash if present, we'll add it in node)
basePath := webBasePath
if basePath != "" && basePath != "/" {
if !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
url += basePath
} else {
url += "/"
}
return url
}
// ReloadNode reloads XRAY on a specific node.
func (s *NodeService) ReloadNode(node *model.Node) error {
client, err := s.createHTTPClient(node, 30*time.Second)
if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
}
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, err := s.createHTTPClient(node, 30*time.Second)
if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
}
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, err := s.createHTTPClient(node, 5*time.Second)
if err != nil {
return fmt.Errorf("failed to create HTTP client: %w", err)
}
// 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("[Node: %s] Failed to connect at %s: %v", node.Name, healthURL, err)
return fmt.Errorf("failed to connect to node: %v", err)
}
healthResp.Body.Close()
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("[Node: %s] Validating API key at %s", node.Name, url)
resp, err := client.Do(req)
if err != nil {
logger.Errorf("[Node: %s] Failed to connect: %v", node.Name, err)
return fmt.Errorf("failed to connect to node: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
logger.Warningf("[Node: %s] Invalid API key: %s", node.Name, string(body))
return fmt.Errorf("invalid API key")
}
if resp.StatusCode != http.StatusOK {
logger.Errorf("[Node: %s] Returned status %d: %s", node.Name, resp.StatusCode, string(body))
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
}
logger.Debugf("[Node: %s] API key validated successfully", node.Name)
return nil
}
// GetNodeStatus retrieves the status of a node.
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
client, err := s.createHTTPClient(node, 5*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
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
}
// GetNodeLogs retrieves XRAY access logs from a node.
// Returns raw log lines as strings.
func (s *NodeService) GetNodeLogs(node *model.Node, count int, filter string) ([]string, error) {
client, err := s.createHTTPClient(node, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
url := fmt.Sprintf("%s/api/v1/logs?count=%d", node.Address, count)
if filter != "" {
url += "&filter=" + filter
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request node logs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
}
var response struct {
Logs []string `json:"logs"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return response.Logs, nil
}

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
} }
@ -763,7 +793,8 @@ func (s *ServerService) GetXrayLogs(
showBlocked string, showBlocked string,
showProxy string, showProxy string,
freedoms []string, freedoms []string,
blackholes []string) []LogEntry { blackholes []string,
nodeId string) []LogEntry {
const ( const (
Direct = iota Direct = iota
@ -774,6 +805,76 @@ 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, get logs from node
if nodeId != "" {
nodeIdInt, err := strconv.Atoi(nodeId)
if err == nil {
nodeService := NodeService{}
node, err := nodeService.GetNode(nodeIdInt)
if err == nil && node != nil {
// Get raw logs from node
rawLogs, err := nodeService.GetNodeLogs(node, countInt, filter)
if err == nil {
// Parse logs into LogEntry format
for _, line := range rawLogs {
var entry LogEntry
parts := strings.Fields(line)
for i, part := range parts {
if i == 0 {
if len(parts) > 1 {
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
if err == nil {
entry.DateTime = dateTime.UTC()
}
}
}
if part == "from" && i+1 < len(parts) {
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
} else if part == "accepted" && i+1 < len(parts) {
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
} else if strings.HasPrefix(part, "[") {
entry.Inbound = part[1:]
} else if strings.HasSuffix(part, "]") {
entry.Outbound = part[:len(part)-1]
} else if part == "email:" && i+1 < len(parts) {
entry.Email = parts[i+1]
}
}
// Determine event type
if logEntryContains(line, freedoms) {
if showDirect == "false" {
continue
}
entry.Event = Direct
} else if logEntryContains(line, blackholes) {
if showBlocked == "false" {
continue
}
entry.Event = Blocked
} else {
if showProxy == "false" {
continue
}
entry.Event = Proxied
}
entries = append(entries, entry)
}
}
}
}
}
// If no nodeId provided or node not found, return empty
return entries
}
pathToAccessLog, err := xray.GetAccessLogPath() pathToAccessLog, err := xray.GetAccessLogPath()
if err != nil { if err != nil {
return nil return nil

View file

@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/random" "github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/util/reflect_util" "github.com/mhsanaei/3x-ui/v2/util/reflect_util"
"github.com/mhsanaei/3x-ui/v2/web/cache"
"github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
@ -94,6 +95,10 @@ 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
// HWID tracking mode
"hwidMode": "client_header", // "off" = disabled, "client_header" = use x-hwid header (default), "legacy_fingerprint" = deprecated fingerprint-based (deprecated)
} }
// SettingService provides business logic for application settings management. // SettingService provides business logic for application settings management.
@ -110,78 +115,85 @@ func (s *SettingService) GetDefaultJsonConfig() (any, error) {
} }
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
db := database.GetDB() var allSetting *entity.AllSetting
settings := make([]*model.Setting, 0)
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
if err != nil {
return nil, err
}
allSetting := &entity.AllSetting{}
t := reflect.TypeOf(allSetting).Elem()
v := reflect.ValueOf(allSetting).Elem()
fields := reflect_util.GetFields(t)
setSetting := func(key, value string) (err error) { err := cache.GetOrSet(cache.KeySettingsAll, &allSetting, cache.TTLSettings, func() (interface{}, error) {
defer func() { // Cache miss - fetch from database
panicErr := recover() db := database.GetDB()
if panicErr != nil { settings := make([]*model.Setting, 0)
err = errors.New(fmt.Sprint(panicErr)) err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
} if err != nil {
}() return nil, err
}
result := &entity.AllSetting{}
t := reflect.TypeOf(result).Elem()
v := reflect.ValueOf(result).Elem()
fields := reflect_util.GetFields(t)
var found bool setSetting := func(key, value string) (err error) {
var field reflect.StructField defer func() {
for _, f := range fields { panicErr := recover()
if f.Tag.Get("json") == key { if panicErr != nil {
field = f err = errors.New(fmt.Sprint(panicErr))
found = true }
break }()
var found bool
var field reflect.StructField
for _, f := range fields {
if f.Tag.Get("json") == key {
field = f
found = true
break
}
} }
if !found {
// Some settings are automatically generated, no need to return to the front end to modify the user
return nil
}
fieldV := v.FieldByName(field.Name)
switch t := fieldV.Interface().(type) {
case int:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return err
}
fieldV.SetInt(n)
case string:
fieldV.SetString(value)
case bool:
fieldV.SetBool(value == "true")
default:
return common.NewErrorf("unknown field %v type %v", key, t)
}
return
} }
if !found { keyMap := map[string]bool{}
// Some settings are automatically generated, no need to return to the front end to modify the user for _, setting := range settings {
return nil err := setSetting(setting.Key, setting.Value)
}
fieldV := v.FieldByName(field.Name)
switch t := fieldV.Interface().(type) {
case int:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil { if err != nil {
return err return nil, err
} }
fieldV.SetInt(n) keyMap[setting.Key] = true
case string:
fieldV.SetString(value)
case bool:
fieldV.SetBool(value == "true")
default:
return common.NewErrorf("unknown field %v type %v", key, t)
} }
return
}
keyMap := map[string]bool{} for key, value := range defaultValueMap {
for _, setting := range settings { if keyMap[key] {
err := setSetting(setting.Key, setting.Value) continue
if err != nil { }
return nil, err err := setSetting(key, value)
if err != nil {
return nil, err
}
} }
keyMap[setting.Key] = true
}
for key, value := range defaultValueMap { return result, nil
if keyMap[key] { })
continue
}
err := setSetting(key, value)
if err != nil {
return nil, err
}
}
return allSetting, nil return allSetting, err
} }
func (s *SettingService) ResetSettings() error { func (s *SettingService) ResetSettings() error {
@ -195,29 +207,54 @@ func (s *SettingService) ResetSettings() error {
} }
func (s *SettingService) getSetting(key string) (*model.Setting, error) { func (s *SettingService) getSetting(key string) (*model.Setting, error) {
db := database.GetDB() cacheKey := cache.KeySettingPrefix + key
setting := &model.Setting{} var setting *model.Setting
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
if err != nil { err := cache.GetOrSet(cacheKey, &setting, cache.TTLSetting, func() (interface{}, error) {
return nil, err // Cache miss - fetch from database
} db := database.GetDB()
return setting, nil result := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", key).First(result).Error
if err != nil {
return nil, err
}
return result, nil
})
return setting, err
} }
func (s *SettingService) saveSetting(key string, value string) error { func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key) setting, err := s.getSetting(key)
db := database.GetDB() db := database.GetDB()
if database.IsNotFound(err) { if database.IsNotFound(err) {
return db.Create(&model.Setting{ err = db.Create(&model.Setting{
Key: key, Key: key,
Value: value, Value: value,
}).Error }).Error
} else if err != nil { } else if err != nil {
return err return err
} else {
setting.Key = key
setting.Value = value
err = db.Save(setting).Error
} }
setting.Key = key
setting.Value = value if err == nil {
return db.Save(setting).Error // Invalidate cache for this specific setting
cache.InvalidateSetting(key)
// Invalidate all settings cache only when a setting is actually changed
// This ensures consistency while avoiding unnecessary cache misses
cache.Delete(cache.KeySettingsAll)
// Also invalidate default settings cache (they depend on individual settings)
cache.DeletePattern("defaultSettings:*")
// Invalidate computed settings that depend on this setting
if key == "multiNodeMode" {
cache.Delete("computed:ipLimitEnable")
}
}
return err
} }
func (s *SettingService) getString(key string) (string, error) { func (s *SettingService) getString(key string) (string, error) {
@ -564,11 +601,26 @@ func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
} }
func (s *SettingService) GetIpLimitEnable() (bool, error) { func (s *SettingService) GetIpLimitEnable() (bool, error) {
accessLogPath, err := xray.GetAccessLogPath() // Cache key for this computed setting
if err != nil { cacheKey := "computed:ipLimitEnable"
return false, err var result bool
}
return (accessLogPath != "none" && accessLogPath != ""), nil err := cache.GetOrSet(cacheKey, &result, cache.TTLSetting, func() (interface{}, 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()
if err != nil {
return false, err
}
return (accessLogPath != "none" && accessLogPath != ""), nil
})
return result, err
} }
// LDAP exported getters // LDAP exported getters
@ -652,6 +704,50 @@ 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)
}
// GetHwidMode returns the HWID tracking mode.
// Returns: "off", "client_header", or "legacy_fingerprint"
func (s *SettingService) GetHwidMode() (string, error) {
mode, err := s.getString("hwidMode")
if err != nil {
return "client_header", err // Default to client_header on error
}
// Validate mode
validModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if !validModes[mode] {
// Invalid mode, return default
return "client_header", nil
}
return mode, nil
}
// SetHwidMode sets the HWID tracking mode.
// Valid values: "off", "client_header", "legacy_fingerprint"
func (s *SettingService) SetHwidMode(mode string) error {
validModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if !validModes[mode] {
return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", mode)
}
return s.setString("hwidMode", mode)
}
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
@ -683,32 +779,44 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
} }
func (s *SettingService) GetDefaultSettings(host string) (any, error) { func (s *SettingService) GetDefaultSettings(host string) (any, error) {
type settingFunc func() (any, error) // Cache key includes host to support multi-domain setups
settings := map[string]settingFunc{ cacheKey := fmt.Sprintf("defaultSettings:%s", host)
"expireDiff": func() (any, error) { return s.GetExpireDiff() }, var result map[string]any
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
}
result := make(map[string]any) err := cache.GetOrSet(cacheKey, &result, cache.TTLSettings, func() (interface{}, error) {
// Cache miss - compute default settings
for key, fn := range settings { type settingFunc func() (any, error)
value, err := fn() settings := map[string]settingFunc{
if err != nil { "expireDiff": func() (any, error) { return s.GetExpireDiff() },
return "", err "trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
} }
result[key] = value
res := make(map[string]any)
for key, fn := range settings {
value, err := fn()
if err != nil {
return nil, err
}
res[key] = value
}
return res, nil
})
if err != nil {
return nil, err
} }
subEnable := result["subEnable"].(bool) subEnable := result["subEnable"].(bool)

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
} }
@ -214,12 +218,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
@ -243,6 +259,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("[Node: %s] Failed to marshal config: %v", node.Name, err)
continue
}
// Send to node
if err := s.nodeService.ApplyConfigToNode(node, configJSON); err != nil {
logger.Errorf("[Node: %s] Failed to apply config: %v", node.Name, err)
// Continue with other nodes even if one fails
} else {
logger.Infof("[Node: %s] Successfully applied config", 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

@ -23,11 +23,17 @@
"indefinite" = "Indefinite" "indefinite" = "Indefinite"
"unlimited" = "Unlimited" "unlimited" = "Unlimited"
"none" = "None" "none" = "None"
"hwidSettings" = "HWID Settings"
"hwidEnabled" = "Enable HWID Restriction"
"maxHwid" = "Max Allowed Devices (HWID)"
"hwidBetaWarningTitle" = "Beta Feature"
"hwidBetaWarningDesc" = "HWID tracking is currently in beta version and works only with happ and v2raytun clients. Other clients may not support HWID registration."
"qrCode" = "QR Code" "qrCode" = "QR Code"
"info" = "More Information" "info" = "More Information"
"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"
@ -71,6 +77,8 @@
"emptyBalancersDesc" = "No added balancers." "emptyBalancersDesc" = "No added balancers."
"emptyReverseDesc" = "No added reverse proxies." "emptyReverseDesc" = "No added reverse proxies."
"somethingWentWrong" = "Something went wrong" "somethingWentWrong" = "Something went wrong"
"active" = "Active"
"inactive" = "Inactive"
[subscription] [subscription]
"title" = "Subscription info" "title" = "Subscription info"
@ -86,17 +94,6 @@
"unlimited" = "Unlimited" "unlimited" = "Unlimited"
"noExpiry" = "No expiry" "noExpiry" = "No expiry"
[menu]
"theme" = "Theme"
"dark" = "Dark"
"ultraDark" = "Ultra Dark"
"dashboard" = "Overview"
"inbounds" = "Inbounds"
"settings" = "Panel Settings"
"xray" = "Xray Configs"
"logout" = "Log Out"
"link" = "Manage"
[pages.login] [pages.login]
"hello" = "Hello" "hello" = "Hello"
"title" = "Welcome" "title" = "Welcome"
@ -117,6 +114,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 +405,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 +591,211 @@
"twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted" "twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted"
"twoFactorModalError" = "Wrong code" "twoFactorModalError" = "Wrong code"
[pages.nodes]
responseTime = "Response Time"
"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"
"connecting" = "Establishing connection"
"generatingApiKey" = "Generating API key"
"registeringNode" = "Registering node"
"done" = "Done"
"connectionEstablished" = "Connection established"
"connectionError" = "Connection error"
"apiKeyGenerated" = "API key generated"
"generationError" = "Generation error"
"nodeRegistered" = "Node registered"
"registrationError" = "Registration error"
"nodeAddedSuccessfully" = "Node added successfully!"
"checkAll" = "Check All"
"check" = "Check"
"online" = "Online"
"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 or domain)"
"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"
"tlsSettings" = "TLS/HTTPS Settings"
"useTls" = "Use TLS/HTTPS"
"useTlsHint" = "Enable TLS/HTTPS for API calls to this node"
"certPath" = "Certificate Path"
"certPathHint" = "Path to CA certificate file (optional, for custom CA)"
"keyPath" = "Private Key Path"
"keyPathHint" = "Path to private key file (optional, for client certificate)"
"insecureTls" = "Skip Certificate Verification"
"insecureTlsHint" = "⚠️ Not recommended: Skip TLS certificate verification (insecure)"
[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.clients]
"title" = "Clients Management"
"addClient" = "Add Client"
"operate" = "Actions"
"email" = "Email"
"inbounds" = "Assigned Inbounds"
"traffic" = "Traffic"
"expiryTime" = "Expiry Time"
"enable" = "Enabled"
"loadError" = "Failed to load clients"
"deleteConfirm" = "Confirm Deletion"
"deleteConfirmText" = "Are you sure you want to delete this client?"
"deleteSuccess" = "Client deleted successfully"
"deleteError" = "Failed to delete client"
"updateSuccess" = "Client updated successfully"
"updateError" = "Failed to update client"
"addClientNotImplemented" = "Add client functionality will be implemented soon"
"editClientNotImplemented" = "Edit client functionality will be implemented soon"
"editClient" = "Edit Client"
"addSuccess" = "Client added successfully"
"addError" = "Failed to add client"
"emailRequired" = "Email is required"
"maxHwidDesc" = "Set 0 for unlimited devices. If a new device connects and the limit is reached, the connection will be blocked."
"registeredHwids" = "Registered Devices"
"registeredHwidsDesc" = "List of hardware IDs (devices) that have connected to this client. Only active devices count towards the limit."
"noHwidsRegistered" = "No devices registered yet."
"confirmDeleteHwid" = "Are you sure you want to delete this device? This will allow a new device to connect if the limit is not reached."
"hwidDeleteSuccess" = "Device deleted successfully."
"hwidDeleteError" = "Failed to delete device."
"deviceInfo" = "Device Information"
"firstSeen" = "First Seen"
"lastSeen" = "Last Seen"
"actions" = "Actions"
[pages.clients.toasts]
"clientCreateSuccess" = "Client created successfully"
"clientUpdateSuccess" = "Client updated successfully"
"clientDeleteSuccess" = "Client deleted successfully"
[pages.hosts]
"title" = "Hosts Management"
"addNewHost" = "Add New Host"
"addHost" = "Add Host"
"hostName" = "Host Name"
"hostAddress" = "Host Address"
"hostPort" = "Port"
"hostProtocol" = "Protocol"
"operate" = "Actions"
"name" = "Name"
"address" = "Address"
"port" = "Port"
"protocol" = "Protocol"
"assignedInbounds" = "Assigned Inbounds"
"enable" = "Enabled"
"multiNodeModeRequired" = "Multi-Node Mode must be enabled to manage hosts"
"enterHostNameAndAddress" = "Please enter host name and address"
"enterHostName" = "Please enter host name"
"enterHostAddress" = "Please enter host address"
"editHost" = "Edit Host"
"modalNotAvailable" = "Host modal is not available"
"loadError" = "Failed to load hosts"
"deleteConfirm" = "Confirm Deletion"
"deleteConfirmText" = "Are you sure you want to delete this host?"
"deleteSuccess" = "Host deleted successfully"
"deleteError" = "Failed to delete host"
"updateSuccess" = "Host updated successfully"
"updateError" = "Failed to update host"
"addSuccess" = "Host added successfully"
"addError" = "Failed to add host"
"editHostNotImplemented" = "Edit host functionality will be implemented soon"
[menu]
"theme" = "Theme"
"dark" = "Dark"
"ultraDark" = "Ultra Dark"
"glassMorphism" = "Glass Morphism"
"dashboard" = "Overview"
"inbounds" = "Inbounds"
"clients" = "Clients"
"settings" = "Panel Settings"
"xray" = "Xray Configs"
"nodes" = "Nodes"
"hosts" = "Hosts"
"logout" = "Log Out"
"link" = "Manage"
"tutorial" = "Tutorial"
"restartTutorial" = "Restart Tutorial"
[tutorial]
"title" = "Web Panel Menu Guide"
"next" = "Next"
"prev" = "Previous"
"skip" = "Skip"
"finish" = "Finish"
"step" = "Step"
"of" = "of"
"dashboardTitle" = "1. Panel"
"dashboardDesc" = "Main interface for managing the entire system. Through the panel we:\n\n• Configure and control nodes (servers with xray)\n• Manage clients\n• Configure inbounds"
"dashboardHint" = "Panel is the control center. Here you create nodes, inbounds and assign clients."
"nodeTitle" = "2. Node"
"nodeDesc" = "Separate Xray core with API for communication with the panel. The node handles connections and serves as the 'brain' for inbounds."
"nodeHint" = "Node is the server core. The panel communicates with it via API to manage configs and connections."
"inboundTitle" = "3. Inbound"
"inboundDesc" = "Configuration or profile for a node.\n\n• Creates a connection to a node\n• Subscribes to one or more nodes"
"inboundHint" = "Inbound is a connection profile. Through it, clients get access to the required nodes."
"clientTitle" = "4. Client"
"clientDesc" = "System user who can be assigned one or more inbounds.\n\nFor example:\n• Inbound 1 → whitelist node\n• Inbound 2 → regular foreign server\n\nClient can use any or all inbounds assigned to them"
"clientHint" = "Client is a user. Assign inbounds to them so they can connect to the required nodes."
"hostTitle" = "5. Hosts"
"hostDesc" = "External addresses for connection.\n\n• Proxy balancer that hides direct node addresses\n• Can distribute load between multiple nodes\n• Replace node address with the required host in inbound\n\nExample:\nInbound connects to a balancer host, which then distributes the connection to real nodes"
"hostHint" = "Hosts are virtual addresses. Use them for load balancing and hiding real servers."
"settingsTitle" = "6. Panel Settings"
"settingsDesc" = "Section for general panel configuration.\n\n• Enable/disable various features\n• Configure panel appearance and behavior"
"settingsHint" = "Panel Settings — here you can manage features and panel configuration."
"xrayTitle" = "7. Xray Configuration"
"xrayDesc" = "Section for fine-tuning Xray core.\n\n• Routing\n• Connection parameters and traffic routing\n• Additional advanced node configs"
"xrayHint" = "Xray Configuration — for advanced core configuration, routing management and other node parameters."
[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

@ -23,11 +23,17 @@
"indefinite" = "Бесконечно" "indefinite" = "Бесконечно"
"unlimited" = "Безлимит" "unlimited" = "Безлимит"
"none" = "Пусто" "none" = "Пусто"
"hwidSettings" = "Настройки HWID"
"hwidEnabled" = "Включить ограничение по HWID"
"maxHwid" = "Максимум устройств (HWID)"
"hwidBetaWarningTitle" = "Бета-функция"
"hwidBetaWarningDesc" = "Отслеживание HWID находится в бета-версии и работает только с клиентами happ и v2raytun. Другие клиенты могут не поддерживать регистрацию HWID."
"qrCode" = "QR-код" "qrCode" = "QR-код"
"info" = "Информация" "info" = "Информация"
"edit" = "Изменить" "edit" = "Изменить"
"delete" = "Удалить" "delete" = "Удалить"
"reset" = "Сбросить" "reset" = "Сбросить"
"refresh" = "Обновить"
"noData" = "Нет данных." "noData" = "Нет данных."
"copySuccess" = "Скопировано" "copySuccess" = "Скопировано"
"sure" = "Да" "sure" = "Да"
@ -71,6 +77,8 @@
"emptyBalancersDesc" = "Нет добавленных балансировщиков." "emptyBalancersDesc" = "Нет добавленных балансировщиков."
"emptyReverseDesc" = "Нет добавленных реверс-прокси." "emptyReverseDesc" = "Нет добавленных реверс-прокси."
"somethingWentWrong" = "Что-то пошло не так" "somethingWentWrong" = "Что-то пошло не так"
"active" = "Активен"
"inactive" = "Неактивен"
[subscription] [subscription]
"title" = "Информация о подписке" "title" = "Информация о подписке"
@ -86,17 +94,6 @@
"unlimited" = "Неограниченно" "unlimited" = "Неограниченно"
"noExpiry" = "Бессрочно" "noExpiry" = "Бессрочно"
[menu]
"theme" = "Тема"
"dark" = "Темная"
"ultraDark" = "Очень темная"
"dashboard" = "Дашборд"
"inbounds" = "Подключения"
"settings" = "Настройки"
"xray" = "Настройки Xray"
"logout" = "Выход"
"link" = "Управление"
[pages.login] [pages.login]
"hello" = "Привет!" "hello" = "Привет!"
"title" = "Добро пожаловать!" "title" = "Добро пожаловать!"
@ -117,6 +114,7 @@
"swap" = "Файл подкачки" "swap" = "Файл подкачки"
"storage" = "Диск" "storage" = "Диск"
"memory" = "ОЗУ" "memory" = "ОЗУ"
"nodesAvailability" = "Доступность нод"
"threads" = "Потоки" "threads" = "Потоки"
"xrayStatus" = "Xray" "xrayStatus" = "Xray"
"stopXray" = "Остановить" "stopXray" = "Остановить"
@ -408,6 +406,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 +591,211 @@
"twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена" "twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена"
"twoFactorModalError" = "Неверный код" "twoFactorModalError" = "Неверный код"
[pages.nodes]
responseTime = "Время ответа"
"title" = "Управление нодами"
"addNewNode" = "Добавить новую ноду"
"addNode" = "Добавить ноду"
"editNode" = "Редактировать ноду"
"deleteNode" = "Удалить ноду"
"checkNode" = "Проверить ноду"
"checkAllNodes" = "Проверить все ноды"
"nodeName" = "Имя ноды"
"nodeAddress" = "Адрес ноды"
"nodePort" = "Порт"
"nodeApiKey" = "API ключ"
"nodeStatus" = "Статус"
"lastCheck" = "Последняя проверка"
"actions" = "Действия"
"operate" = "Действия"
"name" = "Имя"
"address" = "Адрес"
"status" = "Статус"
"assignedInbounds" = "Назначенные подключения"
"connecting" = "Устанавливаю соединение"
"generatingApiKey" = "Генерирую API ключ"
"registeringNode" = "Регистрирую ноду"
"done" = "Готово"
"connectionEstablished" = "Соединение установлено"
"connectionError" = "Ошибка соединения"
"apiKeyGenerated" = "API ключ сгенерирован"
"generationError" = "Ошибка генерации"
"nodeRegistered" = "Нода зарегистрирована"
"registrationError" = "Ошибка регистрации"
"nodeAddedSuccessfully" = "Нода успешно добавлена!"
"checkAll" = "Проверить все"
"check" = "Проверить"
"online" = "Онлайн"
"offline" = "Офлайн"
"error" = "Ошибка"
"unknown" = "Неизвестно"
"enterNodeName" = "Пожалуйста, введите имя ноды"
"enterNodeAddress" = "Пожалуйста, введите адрес ноды"
"validUrl" = "Должен быть действительным URL (http:// или https://)"
"validPort" = "Порт должен быть числом от 1 до 65535"
"duplicateNode" = "Нода с таким адресом и портом уже существует"
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100 или домен)"
"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" = "Не удалось перезагрузить некоторые ноды"
"tlsSettings" = "Настройки TLS/HTTPS"
"useTls" = "Использовать TLS/HTTPS"
"useTlsHint" = "Включить TLS/HTTPS для API вызовов к этой ноде"
"certPath" = "Путь к сертификату"
"certPathHint" = "Путь к файлу сертификата CA (опционально, для кастомного CA)"
"keyPath" = "Путь к приватному ключу"
"keyPathHint" = "Путь к файлу приватного ключа (опционально, для клиентского сертификата)"
"insecureTls" = "Пропустить проверку сертификата"
"insecureTlsHint" = "⚠️ Не рекомендуется: пропустить проверку TLS сертификата (небезопасно)"
[pages.nodes.toasts]
"createSuccess" = "Нода успешно создана"
"createError" = "Не удалось создать ноду"
"checkStatusSuccess" = "Проверка здоровья ноды завершена"
"checkStatusError" = "Не удалось проверить статус ноды"
"obtainError" = "Не удалось получить список нод"
"invalidId" = "Неверный ID ноды"
"assignSuccess" = "Подключение успешно назначено на ноду"
"assignError" = "Не удалось назначить подключение на ноду"
"mappingError" = "Не удалось получить привязку ноды"
"invalidInboundId" = "Неверный ID подключения"
[pages.clients]
"title" = "Управление клиентами"
"addClient" = "Добавить клиента"
"operate" = "Действия"
"email" = "Email"
"inbounds" = "Назначенные подключения"
"traffic" = "Трафик"
"expiryTime" = "Срок действия"
"enable" = "Включено"
"loadError" = "Не удалось загрузить список клиентов"
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить этого клиента?"
"deleteSuccess" = "Клиент успешно удален"
"deleteError" = "Не удалось удалить клиента"
"updateSuccess" = "Клиент успешно обновлен"
"updateError" = "Не удалось обновить клиента"
"addClientNotImplemented" = "Функция добавления клиента будет реализована в ближайшее время"
"editClientNotImplemented" = "Функция редактирования клиента будет реализована в ближайшее время"
"editClient" = "Редактировать клиента"
"addSuccess" = "Клиент успешно добавлен"
"addError" = "Не удалось добавить клиента"
"emailRequired" = "Email обязателен для заполнения"
"maxHwidDesc" = "Установите 0 для неограниченного количества устройств. Если новое устройство подключается и лимит достигнут, подключение будет заблокировано."
"registeredHwids" = "Зарегистрированные устройства"
"registeredHwidsDesc" = "Список аппаратных ID (устройств), которые подключались к этому клиенту. Только активные устройства учитываются в лимите."
"noHwidsRegistered" = "Устройства еще не зарегистрированы."
"confirmDeleteHwid" = "Вы уверены, что хотите удалить это устройство? Это позволит новому устройству подключиться, если лимит не достигнут."
"hwidDeleteSuccess" = "Устройство успешно удалено."
"hwidDeleteError" = "Не удалось удалить устройство."
"deviceInfo" = "Информация об устройстве"
"firstSeen" = "Первое подключение"
"lastSeen" = "Последнее подключение"
"actions" = "Действия"
[pages.clients.toasts]
"clientCreateSuccess" = "Клиент успешно создан"
"clientUpdateSuccess" = "Клиент успешно обновлен"
"clientDeleteSuccess" = "Клиент успешно удален"
[pages.hosts]
"title" = "Управление хостами"
"addNewHost" = "Добавить новый хост"
"addHost" = "Добавить хост"
"hostName" = "Имя хоста"
"hostAddress" = "Адрес хоста"
"hostPort" = "Порт"
"hostProtocol" = "Протокол"
"operate" = "Действия"
"name" = "Имя"
"address" = "Адрес"
"port" = "Порт"
"protocol" = "Протокол"
"assignedInbounds" = "Назначенные подключения"
"enable" = "Включено"
"multiNodeModeRequired" = "Для управления хостами должен быть включен режим Multi-Node"
"enterHostNameAndAddress" = "Пожалуйста, введите имя и адрес хоста"
"enterHostName" = "Пожалуйста, введите имя хоста"
"enterHostAddress" = "Пожалуйста, введите адрес хоста"
"editHost" = "Редактировать хост"
"modalNotAvailable" = "Модальное окно хоста недоступно"
"loadError" = "Не удалось загрузить список хостов"
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить этот хост?"
"deleteSuccess" = "Хост успешно удален"
"deleteError" = "Не удалось удалить хост"
"updateSuccess" = "Хост успешно обновлен"
"updateError" = "Не удалось обновить хост"
"addSuccess" = "Хост успешно добавлен"
"addError" = "Не удалось добавить хост"
"editHostNotImplemented" = "Функция редактирования хоста будет реализована в ближайшее время"
[menu]
"theme" = "Тема"
"dark" = "Темная"
"ultraDark" = "Очень темная"
"glassMorphism" = "Glass Morphism"
"dashboard" = "Обзор"
"inbounds" = "Подключения"
"clients" = "Клиенты"
"settings" = "Настройки панели"
"xray" = "Конфигурация Xray"
"nodes" = "Ноды"
"hosts" = "Хосты"
"logout" = "Выйти"
"link" = "Управление"
"tutorial" = "Обучалка"
"restartTutorial" = "Повторить обучение"
[tutorial]
"title" = "Инструкция по меню веб-панели"
"next" = "Далее"
"prev" = "Назад"
"skip" = "Пропустить"
"finish" = "Завершить"
"step" = "Шаг"
"of" = "из"
"dashboardTitle" = "1. Панель"
"dashboardDesc" = "Главный интерфейс для управления всей системой. Через панель мы:\n\n• Настраиваем и контролируем ноды (серверы с xray)\n• Управляем клиентами\n• Настраиваем инбаунды"
"dashboardHint" = "Панель — это центр управления. Здесь создаются ноды, инбаунды и назначаются клиенты."
"nodeTitle" = "2. Нода"
"nodeDesc" = "Отдельное ядро Xray с API для связи с панелью. Нода выполняет работу по обработке подключений и служит «мозгом» для инбаундов."
"nodeHint" = "Нода — это серверное ядро. Панель взаимодействует с ним через API, чтобы управлять конфигами и подключениями."
"inboundTitle" = "3. Инбаунд"
"inboundDesc" = "Конфигурация или профиль для ноды.\n\n• Создаётся подключение к ноде\n• Подписывается на одну или несколько нод"
"inboundHint" = "Инбаунд — это профиль подключения. Через него клиенты получают доступ к нужным нодам."
"clientTitle" = "4. Клиент"
"clientDesc" = "Пользователь системы, которому можно назначать один или несколько инбаундов.\n\nНапример:\n• Инбаунд 1 → нода из белого списка\n• Инбаунд 2 → обычный забугорный сервер\n\nКлиент может использовать любой или все инбаунды, которые ему назначены"
"clientHint" = "Клиент — это пользователь. Назначайте ему инбаунды, чтобы он мог подключаться к нужным нодам."
"hostTitle" = "5. Хосты"
"hostDesc" = "Внешние адреса для подключения.\n\n• Прокси-балансир, скрывающий прямые адреса нод\n• Можно распределять нагрузку между несколькими нодами\n• Подменяем адрес ноды на нужный хост в инбаунде\n\nПример:\nИнбаунд подключается к хосту-балансиру, а тот уже распределяет подключение на реальные ноды"
"hostHint" = "Хосты — это виртуальные адреса. Используйте их для балансировки нагрузки и скрытия реальных серверов."
"settingsTitle" = "6. Настройки панели"
"settingsDesc" = "Раздел для общей конфигурации панели.\n\n• Включение/отключение различных функций\n• Настройка внешнего вида и поведения панели"
"settingsHint" = "Настройки панели — здесь вы можете управлять функциями и конфигурацией самой панели."
"xrayTitle" = "7. Конфигурация Xray"
"xrayDesc" = "Раздел для тонкой настройки ядра Xray.\n\n• Роутинг\n• Параметры соединений и маршрутизации трафика\n• Дополнительные продвинутые конфиги ноды"
"xrayHint" = "Конфигурация Xray — для продвинутой конфигурации ядра, управления роутингом и другими параметрами ноды."
[pages.settings.toasts] [pages.settings.toasts]
"modifySettings" = "Настройки изменены" "modifySettings" = "Настройки изменены"
"getSettings" = "Произошла ошибка при получении параметров." "getSettings" = "Произошла ошибка при получении параметров."

View file

@ -31,6 +31,7 @@ import (
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v2/web/cache"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
@ -203,7 +204,19 @@ func (s *Server) initRouter() (*gin.Engine, error) {
engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{basePath + "panel/api/"}))) engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{basePath + "panel/api/"})))
assetsBasePath := basePath + "assets/" assetsBasePath := basePath + "assets/"
store := cookie.NewStore(secret) // Use Redis store for sessions if available, otherwise fallback to cookie store
var store sessions.Store
redisClient := cache.GetClient()
if redisClient != nil {
// Use Redis store
store = cache.NewRedisStore(redisClient, []byte(secret))
logger.Info("Using Redis store for sessions")
} else {
// Fallback to cookie store
store = cookie.NewStore(secret)
logger.Info("Using cookie store for sessions (Redis not available)")
}
// Configure default session cookie options, including expiration (MaxAge) // Configure default session cookie options, including expiration (MaxAge)
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil { if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil {
store.Options(sessions.Options{ store.Options(sessions.Options{
@ -220,7 +233,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
engine.Use(func(c *gin.Context) { engine.Use(func(c *gin.Context) {
uri := c.Request.RequestURI uri := c.Request.RequestURI
if strings.HasPrefix(uri, assetsBasePath) { if strings.HasPrefix(uri, assetsBasePath) {
c.Header("Cache-Control", "max-age=31536000") // Cache static assets for 1 year with immutable flag
c.Header("Cache-Control", "max-age=31536000, public, immutable")
} else if strings.HasPrefix(uri, basePath+"panel/api/") && c.Request.Method == "GET" {
// For API GET requests, use no-cache but allow conditional requests
// This enables browser caching with validation
c.Header("Cache-Control", "no-cache, must-revalidate")
} }
}) })
@ -314,13 +332,16 @@ func (s *Server) startTask() {
go func() { go func() {
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
// Statistics every 10 seconds, start the delay for 5 seconds for the first time, and staggered with the time to restart xray // Statistics every 3 seconds for faster traffic limit enforcement, start the delay for 5 seconds for the first time, and staggered with the time to restart xray
s.cron.AddJob("@every 10s", job.NewXrayTrafficJob()) s.cron.AddJob("@every 3s", job.NewXrayTrafficJob())
}() }()
// check client ips from log file every 10 sec // check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob()) s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
// Check client HWIDs from log file every 30 seconds
s.cron.AddJob("@every 30s", job.NewCheckClientHWIDJob())
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
@ -343,6 +364,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()
@ -490,3 +516,13 @@ func (s *Server) GetCron() *cron.Cron {
func (s *Server) GetWSHub() any { func (s *Server) GetWSHub() any {
return s.wsHub return s.wsHub
} }
// InitRedisCache initializes Redis cache. If redisAddr is empty, uses embedded Redis.
func InitRedisCache(redisAddr string) error {
return cache.InitRedis(redisAddr)
}
// CloseRedisCache closes Redis cache connection.
func CloseRedisCache() error {
return cache.Close()
}

View file

@ -2,6 +2,7 @@
package websocket package websocket
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"runtime" "runtime"
@ -21,6 +22,7 @@ const (
MessageTypeNotification MessageType = "notification" // System notification MessageTypeNotification MessageType = "notification" // System notification
MessageTypeXrayState MessageType = "xray_state" // Xray state change MessageTypeXrayState MessageType = "xray_state" // Xray state change
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
MessageTypeNodes MessageType = "nodes" // Nodes list update
) )
// Message represents a WebSocket message // Message represents a WebSocket message
@ -62,6 +64,15 @@ type Hub struct {
// Worker pool for parallel broadcasting // Worker pool for parallel broadcasting
workerPoolSize int workerPoolSize int
broadcastWg sync.WaitGroup broadcastWg sync.WaitGroup
// Cache for last serialized messages to avoid re-serialization
messageCache map[MessageType][]byte
cacheMu sync.RWMutex
// Throttling for frequent updates
throttleMap map[MessageType]time.Time
throttleMu sync.Mutex
throttleInterval time.Duration
} }
// NewHub creates a new WebSocket hub // NewHub creates a new WebSocket hub
@ -85,6 +96,9 @@ func NewHub() *Hub {
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
workerPoolSize: workerPoolSize, workerPoolSize: workerPoolSize,
messageCache: make(map[MessageType][]byte),
throttleMap: make(map[MessageType]time.Time),
throttleInterval: 100 * time.Millisecond, // Throttle updates to max 10 per second per type
} }
} }
@ -259,18 +273,37 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
return return
} }
// Throttle frequent updates (except for critical messages)
if messageType == MessageTypeInbounds || messageType == MessageTypeTraffic {
h.throttleMu.Lock()
lastTime, exists := h.throttleMap[messageType]
if exists && time.Since(lastTime) < h.throttleInterval {
h.throttleMu.Unlock()
return // Skip this update, too soon
}
h.throttleMap[messageType] = time.Now()
h.throttleMu.Unlock()
}
// Use buffer pool for JSON encoding to reduce allocations
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // Faster encoding, no HTML escaping needed
msg := Message{ msg := Message{
Type: messageType, Type: messageType,
Payload: payload, Payload: payload,
Time: getCurrentTimestamp(), Time: getCurrentTimestamp(),
} }
data, err := json.Marshal(msg) if err := enc.Encode(msg); err != nil {
if err != nil {
logger.Error("Failed to marshal WebSocket message:", err) logger.Error("Failed to marshal WebSocket message:", err)
return return
} }
// Remove trailing newline from Encode
data := bytes.TrimRight(buf.Bytes(), "\n")
// Limit message size to prevent memory issues // Limit message size to prevent memory issues
const maxMessageSize = 1024 * 1024 // 1MB const maxMessageSize = 1024 * 1024 // 1MB
if len(data) > maxMessageSize { if len(data) > maxMessageSize {
@ -278,6 +311,14 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
return return
} }
// Cache the serialized message for potential reuse
// Make a copy to avoid issues with buffer reuse
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
h.cacheMu.Lock()
h.messageCache[messageType] = dataCopy
h.cacheMu.Unlock()
// Non-blocking send with timeout to prevent delays // Non-blocking send with timeout to prevent delays
select { select {
case h.broadcast <- data: case h.broadcast <- data:
@ -298,18 +339,25 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
return return
} }
// Use buffer pool for JSON encoding to reduce allocations
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // Faster encoding, no HTML escaping needed
msg := Message{ msg := Message{
Type: messageType, Type: messageType,
Payload: payload, Payload: payload,
Time: getCurrentTimestamp(), Time: getCurrentTimestamp(),
} }
data, err := json.Marshal(msg) if err := enc.Encode(msg); err != nil {
if err != nil {
logger.Error("Failed to marshal WebSocket message:", err) logger.Error("Failed to marshal WebSocket message:", err)
return return
} }
// Remove trailing newline from Encode
data := bytes.TrimRight(buf.Bytes(), "\n")
// Limit message size to prevent memory issues // Limit message size to prevent memory issues
const maxMessageSize = 1024 * 1024 // 1MB const maxMessageSize = 1024 * 1024 // 1MB
if len(data) > maxMessageSize { if len(data) > maxMessageSize {
@ -317,6 +365,14 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
return return
} }
// Cache the serialized message for potential reuse
// Make a copy to avoid issues with buffer reuse
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
h.cacheMu.Lock()
h.messageCache[messageType] = dataCopy
h.cacheMu.Unlock()
h.mu.RLock() h.mu.RLock()
// Filter clients by topics and quickly release lock // Filter clients by topics and quickly release lock
subscribedClients := make([]*Client, 0) subscribedClients := make([]*Client, 0)

View file

@ -80,3 +80,11 @@ func BroadcastXrayState(state string, errorMsg string) {
hub.Broadcast(MessageTypeXrayState, stateUpdate) hub.Broadcast(MessageTypeXrayState, stateUpdate)
} }
} }
// BroadcastNodes broadcasts nodes list update to all connected clients
func BroadcastNodes(nodes any) {
hub := GetHub()
if hub != nil {
hub.Broadcast(MessageTypeNodes, nodes)
}
}

View file

@ -240,6 +240,10 @@ func (x *XrayAPI) GetTraffic(reset bool) ([]*Traffic, []*ClientTraffic, error) {
} }
// processTraffic aggregates a traffic stat into trafficMap using regex matches and value. // processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
// Note: In Xray API terminology:
// - "downlink" = traffic from client to server → maps to Traffic.Down (from server perspective)
// - "uplink" = traffic from server to client → maps to Traffic.Up (from server perspective)
// For inbounds: downlink is what clients send (server receives), uplink is what server sends (clients receive)
func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) { func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
isInbound := matches[1] == "inbound" isInbound := matches[1] == "inbound"
tag := matches[2] tag := matches[2]
@ -259,14 +263,19 @@ func processTraffic(matches []string, value int64, trafficMap map[string]*Traffi
trafficMap[tag] = traffic trafficMap[tag] = traffic
} }
// Direct mapping: downlink → Down, uplink → Up
if isDown { if isDown {
traffic.Down = value traffic.Down = value // downlink = traffic from clients to server
} else { } else {
traffic.Up = value traffic.Up = value // uplink = traffic from server to clients
} }
} }
// processClientTraffic updates clientTrafficMap with upload/download values for a client email. // processClientTraffic updates clientTrafficMap with upload/download values for a client email.
// Note: In Xray API terminology:
// - "downlink" = traffic from client to server → maps to ClientTraffic.Down
// - "uplink" = traffic from server to client → maps to ClientTraffic.Up
// This matches the server perspective and is consistent with processTraffic for inbounds.
func processClientTraffic(matches []string, value int64, clientTrafficMap map[string]*ClientTraffic) { func processClientTraffic(matches []string, value int64, clientTrafficMap map[string]*ClientTraffic) {
email := matches[1] email := matches[1]
isDown := matches[2] == "downlink" isDown := matches[2] == "downlink"
@ -277,10 +286,11 @@ func processClientTraffic(matches []string, value int64, clientTrafficMap map[st
clientTrafficMap[email] = traffic clientTrafficMap[email] = traffic
} }
// Direct mapping: downlink → Down, uplink → Up (consistent with processTraffic)
if isDown { if isDown {
traffic.Down = value traffic.Down = value // downlink = traffic from client to server
} else { } else {
traffic.Up = value traffic.Up = value // uplink = traffic from server to client
} }
} }

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