mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
Merge 7d863ff469 into a691eaea8d
This commit is contained in:
commit
ddf72c186f
84 changed files with 14221 additions and 1042 deletions
|
|
@ -38,6 +38,13 @@ func initModels() error {
|
|||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.Node{},
|
||||
&model.InboundNodeMapping{},
|
||||
&model.ClientEntity{},
|
||||
&model.ClientInboundMapping{},
|
||||
&model.Host{},
|
||||
&model.HostInboundMapping{},
|
||||
&model.ClientHWID{}, // HWID tracking for clients
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ type Inbound struct {
|
|||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
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.
|
||||
|
|
@ -105,6 +107,8 @@ type Setting struct {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
ID string `json:"id"` // Unique client identifier
|
||||
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
|
||||
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"
|
||||
}
|
||||
|
|
@ -13,4 +13,4 @@ services:
|
|||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
tty: true
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
14
go.mod
14
go.mod
|
|
@ -1,5 +1,9 @@
|
|||
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
|
||||
|
||||
require (
|
||||
|
|
@ -32,13 +36,16 @@ require (
|
|||
|
||||
require (
|
||||
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/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // 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/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.12 // 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/quic-go/qpack v0.6.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/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // 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/netns v0.0.5 // 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
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
|
|
@ -101,3 +110,8 @@ require (
|
|||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // 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
10
go.sum
|
|
@ -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/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/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/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
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/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
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/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
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-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-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/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
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/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
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/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
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/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
|
|
@ -69,12 +70,19 @@ func initDefaultBackend() logging.Backend {
|
|||
includeTime = true
|
||||
} else {
|
||||
// Unix-like: Try syslog, fallback to stderr
|
||||
if syslogBackend, err := logging.NewSyslogBackend(""); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err)
|
||||
backend = logging.NewLogBackend(os.Stderr, "", 0)
|
||||
includeTime = os.Getppid() > 0
|
||||
} else {
|
||||
// Try syslog with "x-ui" tag first
|
||||
if syslogBackend, err := logging.NewSyslogBackend("x-ui"); err == nil {
|
||||
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,
|
||||
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.
|
||||
|
|
|
|||
7
main.go
7
main.go
|
|
@ -50,6 +50,13 @@ func runWebServer() {
|
|||
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
|
||||
server = web.NewServer()
|
||||
global.SetWebServer(server)
|
||||
|
|
|
|||
124
node/Dockerfile
Normal file
124
node/Dockerfile
Normal 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
79
node/README.md
Normal 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
303
node/api/server.go
Normal 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
|
||||
})
|
||||
}
|
||||
156
node/config/config.go
Normal file
156
node/config/config.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Package config provides node configuration management, including API key persistence.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NodeConfig represents the node's configuration stored on disk.
|
||||
type NodeConfig struct {
|
||||
ApiKey string `json:"apiKey"` // API key for authentication with panel
|
||||
PanelURL string `json:"panelUrl"` // Panel URL (optional, can be set via env var)
|
||||
NodeAddress string `json:"nodeAddress"` // Node's own address (optional)
|
||||
}
|
||||
|
||||
var (
|
||||
config *NodeConfig
|
||||
configMu sync.RWMutex
|
||||
configPath string
|
||||
)
|
||||
|
||||
// InitConfig initializes the configuration system and loads existing config if available.
|
||||
// configDir is the directory where config file will be stored (e.g., "bin", "/app/bin").
|
||||
func InitConfig(configDir string) error {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
// Determine config file path
|
||||
if configDir == "" {
|
||||
// Try common paths
|
||||
possibleDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"}
|
||||
for _, dir := range possibleDirs {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
configDir = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
if configDir == "" {
|
||||
configDir = "." // Fallback to current directory
|
||||
}
|
||||
}
|
||||
|
||||
configPath = filepath.Join(configDir, "node-config.json")
|
||||
|
||||
// Try to load existing config
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
var loadedConfig NodeConfig
|
||||
if err := json.Unmarshal(data, &loadedConfig); err == nil {
|
||||
config = &loadedConfig
|
||||
return nil
|
||||
}
|
||||
// If file exists but is invalid, we'll create a new one
|
||||
}
|
||||
|
||||
// Create empty config if file doesn't exist
|
||||
config = &NodeConfig{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfig returns the current node configuration.
|
||||
func GetConfig() *NodeConfig {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
|
||||
if config == nil {
|
||||
return &NodeConfig{}
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modifications
|
||||
return &NodeConfig{
|
||||
ApiKey: config.ApiKey,
|
||||
PanelURL: config.PanelURL,
|
||||
NodeAddress: config.NodeAddress,
|
||||
}
|
||||
}
|
||||
|
||||
// SetApiKey sets the API key and saves it to disk.
|
||||
// If an API key already exists, it will not be overwritten unless force is true.
|
||||
func SetApiKey(apiKey string, force bool) error {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
if config == nil {
|
||||
config = &NodeConfig{}
|
||||
}
|
||||
|
||||
// Check if API key already exists
|
||||
if config.ApiKey != "" && !force {
|
||||
return fmt.Errorf("API key already exists. Use force=true to overwrite")
|
||||
}
|
||||
|
||||
config.ApiKey = apiKey
|
||||
return saveConfig()
|
||||
}
|
||||
|
||||
// SetPanelURL sets the panel URL and saves it to disk.
|
||||
func SetPanelURL(url string) error {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
if config == nil {
|
||||
config = &NodeConfig{}
|
||||
}
|
||||
|
||||
config.PanelURL = url
|
||||
return saveConfig()
|
||||
}
|
||||
|
||||
// SetNodeAddress sets the node address and saves it to disk.
|
||||
func SetNodeAddress(address string) error {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
if config == nil {
|
||||
config = &NodeConfig{}
|
||||
}
|
||||
|
||||
config.NodeAddress = address
|
||||
return saveConfig()
|
||||
}
|
||||
|
||||
// saveConfig saves the current configuration to disk.
|
||||
func saveConfig() error {
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("config path not initialized, call InitConfig first")
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Marshal config to JSON
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Write to file with proper permissions (readable/writable by owner only)
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfigPath returns the path to the config file.
|
||||
func GetConfigPath() string {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return configPath
|
||||
}
|
||||
64
node/docker-compose.yml
Normal file
64
node/docker-compose.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
services:
|
||||
node:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: node/Dockerfile
|
||||
container_name: 3x-ui-node
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||
#- NODE_API_KEY=test-key
|
||||
- PANEL_URL=http://192.168.0.7:2054
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "44000:44000"
|
||||
volumes:
|
||||
- ./bin/config.json:/app/bin/config.json
|
||||
- ./logs:/app/logs
|
||||
# Note: config.json is mounted directly for persistence
|
||||
# If the file doesn't exist, it will be created when XRAY config is first applied
|
||||
networks:
|
||||
- xray-network
|
||||
node2:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: node/Dockerfile
|
||||
container_name: 3x-ui-node2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
|
||||
#- NODE_API_KEY=test-key1
|
||||
- PANEL_URL=http://192.168.0.7:2054
|
||||
ports:
|
||||
- "8081:8080"
|
||||
- "44001:44001"
|
||||
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
|
||||
|
||||
node3:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: node/Dockerfile
|
||||
container_name: 3x-ui-node3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
#- NODE_API_KEY=test-key
|
||||
- PANEL_URL=http://192.168.0.7:2054
|
||||
ports:
|
||||
- "8082:8080"
|
||||
- "44002:44002"
|
||||
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
346
node/logs/pusher.go
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
// Package logs provides log pushing functionality for sending logs from node to panel in real-time.
|
||||
package logs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
)
|
||||
|
||||
// LogPusher sends logs to the panel in real-time.
|
||||
type LogPusher struct {
|
||||
panelURL string
|
||||
apiKey string
|
||||
nodeAddress string // Node's own address for identification
|
||||
logBuffer []string
|
||||
bufferMu sync.Mutex
|
||||
client *http.Client
|
||||
enabled bool
|
||||
lastPush time.Time
|
||||
pushTicker *time.Ticker
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
pusher *LogPusher
|
||||
pusherOnce sync.Once
|
||||
pusherMu sync.RWMutex
|
||||
)
|
||||
|
||||
// InitLogPusher initializes the log pusher if panel URL and API key are configured.
|
||||
// nodeAddress is the address of this node (e.g., "http://192.168.0.7:8080") for identification.
|
||||
func InitLogPusher(nodeAddress string) {
|
||||
pusherOnce.Do(func() {
|
||||
// Try to get API key from (in order of priority):
|
||||
// 1. Environment variable
|
||||
// 2. Saved config file
|
||||
apiKey := os.Getenv("NODE_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Try to load from saved config
|
||||
cfg := getNodeConfig()
|
||||
if cfg != nil && cfg.ApiKey != "" {
|
||||
apiKey = cfg.ApiKey
|
||||
logger.Debug("Using API key from saved configuration for log pusher")
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
logger.Debug("Log pusher disabled: no API key found (will be enabled after registration)")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get panel URL from environment variable first, then from saved config
|
||||
panelURL := os.Getenv("PANEL_URL")
|
||||
if panelURL == "" {
|
||||
cfg := getNodeConfig()
|
||||
if cfg != nil && cfg.PanelURL != "" {
|
||||
panelURL = cfg.PanelURL
|
||||
logger.Debug("Using panel URL from saved configuration for log pusher")
|
||||
}
|
||||
}
|
||||
|
||||
pusher = &LogPusher{
|
||||
panelURL: panelURL,
|
||||
apiKey: apiKey,
|
||||
nodeAddress: nodeAddress,
|
||||
logBuffer: make([]string, 0, 10),
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
enabled: panelURL != "", // Enable only if panel URL is set
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if pusher.enabled {
|
||||
// Start periodic push (every 2 seconds or when buffer is full)
|
||||
pusher.pushTicker = time.NewTicker(2 * time.Second)
|
||||
go pusher.run()
|
||||
logger.Debugf("Log pusher initialized: sending logs to %s", panelURL)
|
||||
} else {
|
||||
logger.Debug("Log pusher initialized but disabled: waiting for panel URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// nodeConfigData represents the node configuration structure.
|
||||
type nodeConfigData struct {
|
||||
ApiKey string `json:"apiKey"`
|
||||
PanelURL string `json:"panelUrl"`
|
||||
NodeAddress string `json:"nodeAddress"`
|
||||
}
|
||||
|
||||
// getNodeConfig is a helper to get node config without circular dependency.
|
||||
// It reads the config file directly to avoid importing the config package.
|
||||
func getNodeConfig() *nodeConfigData {
|
||||
configPaths := []string{"bin/node-config.json", "config/node-config.json", "./node-config.json", "/app/bin/node-config.json", "/app/config/node-config.json"}
|
||||
|
||||
for _, path := range configPaths {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
var config nodeConfigData
|
||||
if err := json.Unmarshal(data, &config); err == nil {
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPanelURL sets the panel URL and enables the log pusher.
|
||||
// PANEL_URL from environment variable has priority and won't be overwritten.
|
||||
func SetPanelURL(url string) {
|
||||
pusherMu.Lock()
|
||||
defer pusherMu.Unlock()
|
||||
|
||||
// Check if PANEL_URL is set in environment - it has priority
|
||||
envPanelURL := os.Getenv("PANEL_URL")
|
||||
if envPanelURL != "" {
|
||||
// Environment variable has priority, ignore URL from config
|
||||
if pusher != nil && pusher.panelURL == envPanelURL {
|
||||
// Already set from env, don't update
|
||||
return
|
||||
}
|
||||
// Use environment variable instead
|
||||
url = envPanelURL
|
||||
logger.Debugf("Using PANEL_URL from environment: %s (ignoring config URL)", envPanelURL)
|
||||
}
|
||||
|
||||
if pusher == nil {
|
||||
// Initialize if not already initialized
|
||||
apiKey := os.Getenv("NODE_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Try to load from saved config
|
||||
cfg := getNodeConfig()
|
||||
if cfg != nil && cfg.ApiKey != "" {
|
||||
apiKey = cfg.ApiKey
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
logger.Debug("Cannot set panel URL: no API key found")
|
||||
return
|
||||
}
|
||||
|
||||
// Get node address from environment if not provided
|
||||
nodeAddress := os.Getenv("NODE_ADDRESS")
|
||||
if nodeAddress == "" {
|
||||
cfg := getNodeConfig()
|
||||
if cfg != nil && cfg.NodeAddress != "" {
|
||||
nodeAddress = cfg.NodeAddress
|
||||
}
|
||||
}
|
||||
|
||||
pusher = &LogPusher{
|
||||
apiKey: apiKey,
|
||||
nodeAddress: nodeAddress,
|
||||
logBuffer: make([]string, 0, 10),
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
logger.Debug("Panel URL cleared, disabling log pusher")
|
||||
pusher.enabled = false
|
||||
if pusher.pushTicker != nil {
|
||||
pusher.pushTicker.Stop()
|
||||
pusher.pushTicker = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
wasEnabled := pusher.enabled
|
||||
pusher.panelURL = url
|
||||
pusher.enabled = true
|
||||
|
||||
if !wasEnabled && pusher.pushTicker == nil {
|
||||
// Start periodic push if it wasn't running
|
||||
pusher.pushTicker = time.NewTicker(2 * time.Second)
|
||||
go pusher.run()
|
||||
logger.Debugf("Log pusher enabled: sending logs to %s", url)
|
||||
} else if wasEnabled && pusher.panelURL != url {
|
||||
logger.Debugf("Log pusher panel URL updated: %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateApiKey updates the API key in the log pusher.
|
||||
// This is called after node registration to enable log pushing.
|
||||
func UpdateApiKey(apiKey string) {
|
||||
pusherMu.Lock()
|
||||
defer pusherMu.Unlock()
|
||||
|
||||
if pusher == nil {
|
||||
logger.Debug("Cannot update API key: log pusher not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
pusher.apiKey = apiKey
|
||||
logger.Debugf("Log pusher API key updated (length: %d)", len(apiKey))
|
||||
|
||||
// If pusher is enabled but wasn't running, start it
|
||||
if pusher.enabled && pusher.pushTicker == nil && pusher.panelURL != "" {
|
||||
pusher.pushTicker = time.NewTicker(2 * time.Second)
|
||||
go pusher.run()
|
||||
logger.Debugf("Log pusher started after API key update")
|
||||
}
|
||||
}
|
||||
|
||||
// PushLog adds a log entry to the buffer for sending to panel.
|
||||
func PushLog(logLine string) {
|
||||
if pusher == nil || !pusher.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip logs that already contain node prefix to avoid infinite loop
|
||||
// These are logs that came from panel and shouldn't be sent back
|
||||
if strings.Contains(logLine, "[Node:") {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip logs about log pushing itself to avoid infinite loop
|
||||
if strings.Contains(logLine, "Logs pushed:") || strings.Contains(logLine, "Failed to push logs") {
|
||||
return
|
||||
}
|
||||
|
||||
pusher.bufferMu.Lock()
|
||||
defer pusher.bufferMu.Unlock()
|
||||
|
||||
pusher.logBuffer = append(pusher.logBuffer, logLine)
|
||||
|
||||
// If buffer is getting large, push immediately
|
||||
if len(pusher.logBuffer) >= 10 {
|
||||
go pusher.push()
|
||||
}
|
||||
}
|
||||
|
||||
// run periodically pushes logs to panel.
|
||||
func (lp *LogPusher) run() {
|
||||
for {
|
||||
select {
|
||||
case <-lp.pushTicker.C:
|
||||
lp.bufferMu.Lock()
|
||||
if len(lp.logBuffer) > 0 {
|
||||
logsToPush := make([]string, len(lp.logBuffer))
|
||||
copy(logsToPush, lp.logBuffer)
|
||||
lp.logBuffer = lp.logBuffer[:0]
|
||||
lp.bufferMu.Unlock()
|
||||
|
||||
go lp.pushLogs(logsToPush)
|
||||
} else {
|
||||
lp.bufferMu.Unlock()
|
||||
}
|
||||
case <-lp.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// push immediately pushes current buffer to panel.
|
||||
func (lp *LogPusher) push() {
|
||||
lp.bufferMu.Lock()
|
||||
if len(lp.logBuffer) == 0 {
|
||||
lp.bufferMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
logsToPush := make([]string, len(lp.logBuffer))
|
||||
copy(logsToPush, lp.logBuffer)
|
||||
lp.logBuffer = lp.logBuffer[:0]
|
||||
lp.bufferMu.Unlock()
|
||||
|
||||
lp.pushLogs(logsToPush)
|
||||
}
|
||||
|
||||
// pushLogs sends logs to the panel.
|
||||
func (lp *LogPusher) pushLogs(logs []string) {
|
||||
if len(logs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Construct panel URL
|
||||
panelEndpoint := lp.panelURL
|
||||
if panelEndpoint[len(panelEndpoint)-1] != '/' {
|
||||
panelEndpoint += "/"
|
||||
}
|
||||
panelEndpoint += "panel/api/node/push-logs"
|
||||
|
||||
// Log push attempt (DEBUG level to avoid sending this log back to panel)
|
||||
logger.Debugf("Logs pushed: %d log entries to %s", len(logs), panelEndpoint)
|
||||
|
||||
// Prepare request
|
||||
reqBody := map[string]interface{}{
|
||||
"apiKey": lp.apiKey,
|
||||
"logs": logs,
|
||||
}
|
||||
// Add node address for identification (in case multiple nodes share the same API key)
|
||||
if lp.nodeAddress != "" {
|
||||
reqBody["nodeAddress"] = lp.nodeAddress
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to marshal log push request to %s: %v", panelEndpoint, err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", panelEndpoint, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create log push request to %s: %v", panelEndpoint, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := lp.client.Do(req)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to push logs to panel at %s: %v (check if panel URL is correct and accessible)", panelEndpoint, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
logger.Errorf("Panel at %s returned non-OK status %d for log push: %s", panelEndpoint, resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
lp.lastPush = time.Now()
|
||||
}
|
||||
|
||||
// Stop stops the log pusher.
|
||||
func Stop() {
|
||||
if pusher != nil && pusher.pushTicker != nil {
|
||||
pusher.pushTicker.Stop()
|
||||
close(pusher.stopCh)
|
||||
// Push remaining logs
|
||||
pusher.push()
|
||||
}
|
||||
}
|
||||
114
node/main.go
Normal file
114
node/main.go
Normal 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
541
node/xray/manager.go
Normal 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
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
service "github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -40,6 +41,10 @@ func NewSUBController(
|
|||
subTitle string,
|
||||
) *SUBController {
|
||||
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{
|
||||
subTitle: subTitle,
|
||||
subPath: subPath,
|
||||
|
|
@ -70,7 +75,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
|||
func (a *SUBController) subs(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
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 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
|
|
@ -127,7 +132,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
|
||||
// Add headers
|
||||
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 {
|
||||
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) {
|
||||
subId := c.Param("subid")
|
||||
_, 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 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
|
||||
// Add headers
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId)
|
||||
|
||||
c.String(200, jsonSub)
|
||||
}
|
||||
}
|
||||
|
||||
// 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("Profile-Update-Interval", updateInterval)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"maps"
|
||||
"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/logger"
|
||||
"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.
|
||||
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)
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
return "", "", err
|
||||
|
|
|
|||
1395
sub/subService.go
1395
sub/subService.go
File diff suppressed because it is too large
Load diff
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -20,11 +20,48 @@ class DBInbound {
|
|||
this.streamSettings = "";
|
||||
this.tag = "";
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
|
|
@ -116,6 +153,13 @@ class DBInbound {
|
|||
sniffing: sniffing,
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1075,6 +1075,8 @@ class Inbound extends XrayCommonClass {
|
|||
this.tag = tag;
|
||||
this.sniffing = sniffing;
|
||||
this.clientStats = clientStats;
|
||||
this.nodeIds = []; // Node IDs array for multi-node mode
|
||||
this.nodeId = null; // Backward compatibility
|
||||
}
|
||||
getClientStats() {
|
||||
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) {
|
||||
let result = [];
|
||||
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;
|
||||
const separationChar = remarkModel.charAt(0);
|
||||
const orderChars = remarkModel.slice(1);
|
||||
|
|
@ -1650,19 +1749,26 @@ class Inbound extends XrayCommonClass {
|
|||
'e': email,
|
||||
'o': '',
|
||||
};
|
||||
|
||||
if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) {
|
||||
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
||||
result.push({
|
||||
remark: r,
|
||||
link: this.genLink(addr, port, 'same', r, client)
|
||||
// Generate links for each node address
|
||||
addressesWithIds.forEach((addrInfo) => {
|
||||
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
||||
result.push({
|
||||
remark: r,
|
||||
link: this.genLink(addrInfo.address, port, 'same', r, client),
|
||||
nodeId: addrInfo.nodeId
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// External proxy takes precedence
|
||||
this.stream.externalProxy.forEach((ep) => {
|
||||
orders['o'] = ep.remark;
|
||||
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
||||
result.push({
|
||||
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') {
|
||||
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) {
|
||||
let links = [];
|
||||
this.clients.forEach((client) => {
|
||||
|
|
@ -1680,11 +1797,20 @@ class Inbound extends XrayCommonClass {
|
|||
});
|
||||
return links.join('\r\n');
|
||||
} 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) {
|
||||
let links = [];
|
||||
this.settings.peers.forEach((p, index) => {
|
||||
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
|
||||
addresses.forEach((addr) => {
|
||||
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');
|
||||
}
|
||||
|
|
@ -1693,7 +1819,7 @@ class Inbound extends XrayCommonClass {
|
|||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound(
|
||||
const inbound = new Inbound(
|
||||
json.port,
|
||||
json.listen,
|
||||
json.protocol,
|
||||
|
|
@ -1702,7 +1828,14 @@ class Inbound extends XrayCommonClass {
|
|||
json.tag,
|
||||
Sniffing.fromJson(json.sniffing),
|
||||
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() {
|
||||
|
|
@ -1710,7 +1843,7 @@ class Inbound extends XrayCommonClass {
|
|||
if (this.canEnableStream() || this.stream?.sockopt) {
|
||||
streamSettings = this.stream.toJson();
|
||||
}
|
||||
return {
|
||||
const result = {
|
||||
port: this.port,
|
||||
listen: this.listen,
|
||||
protocol: this.protocol,
|
||||
|
|
@ -1720,6 +1853,11 @@ class Inbound extends XrayCommonClass {
|
|||
sniffing: this.sniffing.toJson(),
|
||||
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 {
|
||||
constructor(protocol,
|
||||
vmesses = [new Inbound.VmessSettings.VMESS()]) {
|
||||
vmesses = []) {
|
||||
super(protocol);
|
||||
this.vmesses = vmesses;
|
||||
}
|
||||
|
|
@ -1880,7 +2018,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass {
|
|||
Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
constructor(
|
||||
protocol,
|
||||
vlesses = [new Inbound.VLESSSettings.VLESS()],
|
||||
vlesses = [],
|
||||
decryption = "none",
|
||||
encryption = "none",
|
||||
fallbacks = [],
|
||||
|
|
@ -2070,7 +2208,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
|
|||
|
||||
Inbound.TrojanSettings = class extends Inbound.Settings {
|
||||
constructor(protocol,
|
||||
trojans = [new Inbound.TrojanSettings.Trojan()],
|
||||
trojans = [],
|
||||
fallbacks = [],) {
|
||||
super(protocol);
|
||||
this.trojans = trojans;
|
||||
|
|
@ -2235,7 +2373,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
|
|||
method = SSMethods.BLAKE3_AES_256_GCM,
|
||||
password = RandomUtil.randomShadowsocksPassword(),
|
||||
network = 'tcp,udp',
|
||||
shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()],
|
||||
shadowsockses = [],
|
||||
ivCheck = false,
|
||||
) {
|
||||
super(protocol);
|
||||
|
|
|
|||
82
web/assets/js/model/node.js
Normal file
82
web/assets/js/model/node.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,10 +72,42 @@ class AllSetting {
|
|||
this.ldapDefaultExpiryDays = 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) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
123
web/cache/cache.go
vendored
Normal file
123
web/cache/cache.go
vendored
Normal 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
137
web/cache/redis.go
vendored
Normal 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
176
web/cache/redisstore.go
vendored
Normal 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()
|
||||
}
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||
|
||||
|
|
@ -36,7 +41,12 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|||
|
||||
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
||||
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
// Main API group
|
||||
// Node push-logs endpoint (no session auth, uses API key)
|
||||
// Register in separate group without session auth middleware
|
||||
nodeAPI := g.Group("/panel/api/node")
|
||||
nodeAPI.POST("/push-logs", a.pushNodeLogs)
|
||||
|
||||
// Main API group with session auth
|
||||
api := g.Group("/panel/api")
|
||||
api.Use(a.checkAPIAuth)
|
||||
|
||||
|
|
@ -56,3 +66,149 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
||||
a.Tgbot.SendBackupToAdmins()
|
||||
}
|
||||
|
||||
// extractPort extracts port number from URL address (e.g., "http://192.168.0.7:8080" -> "8080")
|
||||
func extractPort(address string) string {
|
||||
re := regexp.MustCompile(`:(\d+)(?:/|$)`)
|
||||
matches := re.FindStringSubmatch(address)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pushNodeLogs receives logs from a node in real-time and adds them to the panel log buffer.
|
||||
// This endpoint is called by nodes when new logs are generated.
|
||||
// It uses API key authentication instead of session authentication.
|
||||
func (a *APIController) pushNodeLogs(c *gin.Context) {
|
||||
type PushLogRequest struct {
|
||||
ApiKey string `json:"apiKey" binding:"required"` // Node API key for authentication
|
||||
NodeAddress string `json:"nodeAddress,omitempty"` // Node's own address for identification (optional, used when multiple nodes share API key)
|
||||
Logs []string `json:"logs" binding:"required"` // Array of log lines in format "timestamp level - message"
|
||||
}
|
||||
|
||||
var req PushLogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Find node by API key and optionally by address
|
||||
nodeService := service.NodeService{}
|
||||
nodes, err := nodeService.GetAllNodes()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get nodes"})
|
||||
return
|
||||
}
|
||||
|
||||
var node *model.Node
|
||||
var matchedByKey []*model.Node // Track nodes with matching API key
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.ApiKey == req.ApiKey {
|
||||
matchedByKey = append(matchedByKey, n)
|
||||
|
||||
// If nodeAddress is provided, match by both API key and address
|
||||
if req.NodeAddress != "" {
|
||||
// Normalize addresses for comparison (remove trailing slashes, etc.)
|
||||
nodeAddr := strings.TrimSuffix(strings.TrimSpace(n.Address), "/")
|
||||
reqAddr := strings.TrimSuffix(strings.TrimSpace(req.NodeAddress), "/")
|
||||
|
||||
// Extract port from both addresses for comparison
|
||||
// This handles cases where node uses localhost but panel has external IP
|
||||
nodePort := extractPort(nodeAddr)
|
||||
reqPort := extractPort(reqAddr)
|
||||
|
||||
// Match by exact address or by port (if addresses don't match exactly)
|
||||
// This allows nodes to use localhost while panel has external IP
|
||||
if nodeAddr == reqAddr || (nodePort != "" && nodePort == reqPort) {
|
||||
node = n
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// If no address provided, use first match (backward compatibility)
|
||||
node = n
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
// Enhanced logging for debugging
|
||||
if len(matchedByKey) > 0 {
|
||||
logger.Debugf("Failed to find node: API key matches %d node(s), but address mismatch. Request address: '%s', Request port: '%s'. Matched nodes: %v",
|
||||
len(matchedByKey), req.NodeAddress, extractPort(req.NodeAddress),
|
||||
func() []string {
|
||||
var addrs []string
|
||||
for _, n := range matchedByKey {
|
||||
addrs = append(addrs, fmt.Sprintf("%s (port: %s)", n.Address, extractPort(n.Address)))
|
||||
}
|
||||
return addrs
|
||||
}())
|
||||
} else {
|
||||
logger.Debugf("Failed to find node: No node found with API key (received %d logs, key length: %d, key prefix: %s). Total nodes in DB: %d",
|
||||
len(req.Logs), len(req.ApiKey),
|
||||
func() string {
|
||||
if len(req.ApiKey) > 4 {
|
||||
return req.ApiKey[:4] + "..."
|
||||
}
|
||||
return req.ApiKey
|
||||
}(), len(nodes))
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log which node is sending logs (for debugging)
|
||||
logger.Debugf("Received %d logs from node: %s (ID: %d, Address: %s, API key length: %d)",
|
||||
len(req.Logs), node.Name, node.Id, node.Address, len(req.ApiKey))
|
||||
|
||||
// Process and add logs to panel buffer
|
||||
for _, logLine := range req.Logs {
|
||||
if logLine == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse log line: format is "timestamp level - message"
|
||||
var level string
|
||||
var message string
|
||||
|
||||
if idx := strings.Index(logLine, " - "); idx != -1 {
|
||||
parts := strings.SplitN(logLine, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
levelPart := strings.TrimSpace(parts[0])
|
||||
levelFields := strings.Fields(levelPart)
|
||||
if len(levelFields) >= 2 {
|
||||
level = strings.ToUpper(levelFields[len(levelFields)-1])
|
||||
message = parts[1]
|
||||
} else {
|
||||
level = "INFO"
|
||||
message = parts[1]
|
||||
}
|
||||
} else {
|
||||
level = "INFO"
|
||||
message = logLine
|
||||
}
|
||||
} else {
|
||||
level = "INFO"
|
||||
message = logLine
|
||||
}
|
||||
|
||||
// Add log to panel buffer with node prefix
|
||||
formattedMessage := fmt.Sprintf("[Node: %s] %s", node.Name, message)
|
||||
switch level {
|
||||
case "DEBUG":
|
||||
logger.Debugf("%s", formattedMessage)
|
||||
case "WARNING":
|
||||
logger.Warningf("%s", formattedMessage)
|
||||
case "ERROR":
|
||||
logger.Errorf("%s", formattedMessage)
|
||||
case "NOTICE":
|
||||
logger.Noticef("%s", formattedMessage)
|
||||
default:
|
||||
logger.Infof("%s", formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logs received"})
|
||||
}
|
||||
|
|
|
|||
467
web/controller/client.go
Normal file
467
web/controller/client.go
Normal 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)
|
||||
}
|
||||
}
|
||||
224
web/controller/client_hwid.go
Normal file
224
web/controller/client_hwid.go
Normal 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
253
web/controller/host.go
Normal 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)
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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/mhsanaei/3x-ui/v2/web/websocket"
|
||||
|
|
@ -103,12 +106,61 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
|||
|
||||
// addInbound creates a new inbound configuration.
|
||||
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{}
|
||||
err := c.ShouldBind(inbound)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to bind inbound data: %v", err)
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
|
||||
return
|
||||
}
|
||||
|
||||
user := session.GetLoginUser(c)
|
||||
inbound.UserId = user.Id
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add inbound: %v", err)
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
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)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
|
|
@ -160,19 +268,151 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
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{
|
||||
Id: id,
|
||||
}
|
||||
// Bind inbound data (nodeIds will be ignored since we handle it separately)
|
||||
err = c.ShouldBind(inbound)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to bind inbound data: %v", err)
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to update inbound: %v", err)
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
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)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
|
|
@ -367,7 +607,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) {
|
|||
|
||||
// onlines retrieves the list of currently online clients.
|
||||
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.
|
||||
|
|
|
|||
561
web/controller/node.go
Normal file
561
web/controller/node.go
Normal 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)
|
||||
}
|
||||
|
|
@ -237,7 +237,8 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
|
|||
blackholes = []string{"blocked"}
|
||||
}
|
||||
|
||||
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
|
||||
nodeId := c.PostForm("nodeId")
|
||||
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes, nodeId)
|
||||
jsonObj(c, logs, nil)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -92,6 +93,16 @@ func getContext(h gin.H) gin.H {
|
|||
a := gin.H{
|
||||
"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 {
|
||||
a[key] = value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type XUIController struct {
|
|||
|
||||
settingController *SettingController
|
||||
xraySettingController *XraySettingController
|
||||
nodeController *NodeController
|
||||
}
|
||||
|
||||
// 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("/settings", a.settings)
|
||||
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.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.
|
||||
|
|
@ -52,3 +62,18 @@ func (a *XUIController) settings(c *gin.Context) {
|
|||
func (a *XUIController) xraySettings(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,15 @@ type AllSetting struct {
|
|||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
|
||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -168,5 +177,15 @@ func (s *AllSetting) CheckValid() error {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
942
web/html/clients.html
Normal file
942
web/html/clients.html
Normal 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" .}}
|
||||
|
|
@ -12,13 +12,6 @@
|
|||
<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-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>
|
||||
<template slot="title">
|
||||
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
||||
|
|
@ -156,10 +149,6 @@
|
|||
<a-icon :style="{ fontSize: '14px' }" type="info-circle"></a-icon>
|
||||
{{ i18n "info" }}
|
||||
</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-icon :style="{ fontSize: '14px' }" type="delete"></a-icon>
|
||||
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<a-theme-switch></a-theme-switch>
|
||||
<a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab"
|
||||
@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>
|
||||
<span v-text="tab.title"></span>
|
||||
</a-menu-item>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<a-theme-switch></a-theme-switch>
|
||||
<a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab"
|
||||
@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>
|
||||
<span v-text="tab.title"></span>
|
||||
</a-menu-item>
|
||||
|
|
@ -39,11 +39,35 @@
|
|||
|
||||
<script>
|
||||
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', {
|
||||
data() {
|
||||
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/',
|
||||
icon: 'dashboard',
|
||||
|
|
@ -54,6 +78,11 @@
|
|||
icon: 'user',
|
||||
title: '{{ i18n "menu.inbounds"}}'
|
||||
},
|
||||
{
|
||||
key: '{{ .base_path }}panel/clients',
|
||||
icon: 'team',
|
||||
title: '{{ i18n "menu.clients"}}'
|
||||
},
|
||||
{
|
||||
key: '{{ .base_path }}panel/settings',
|
||||
icon: 'setting',
|
||||
|
|
@ -63,21 +92,29 @@
|
|||
key: '{{ .base_path }}panel/xray',
|
||||
icon: 'tool',
|
||||
title: '{{ i18n "menu.xray"}}'
|
||||
},
|
||||
{
|
||||
key: '{{ .base_path }}logout/',
|
||||
icon: 'logout',
|
||||
title: '{{ i18n "menu.logout"}}'
|
||||
},
|
||||
],
|
||||
activeTab: [
|
||||
'{{ .request_uri }}'
|
||||
],
|
||||
visible: false,
|
||||
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
];
|
||||
|
||||
// Add Nodes and Hosts menu items if multi-node mode is enabled
|
||||
if (this.multiNodeMode) {
|
||||
this.tabs.splice(4, 0, {
|
||||
key: '{{ .base_path }}panel/nodes',
|
||||
icon: 'cluster',
|
||||
title: '{{ i18n "menu.nodes"}}'
|
||||
});
|
||||
this.tabs.splice(5, 0, {
|
||||
key: '{{ .base_path }}panel/hosts',
|
||||
icon: 'cloud-server',
|
||||
title: '{{ i18n "menu.hosts"}}'
|
||||
});
|
||||
}
|
||||
|
||||
this.tabs.push({
|
||||
key: '{{ .base_path }}logout/',
|
||||
icon: 'logout',
|
||||
title: '{{ i18n "menu.logout"}}'
|
||||
});
|
||||
},
|
||||
openLink(key) {
|
||||
return key.startsWith('http') ?
|
||||
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"}}`,
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<a-menu-item id="change-theme" class="ant-menu-theme-switch" @mousedown="themeSwitcher.animationsOff()">
|
||||
<span>{{ i18n "menu.dark" }}</span>
|
||||
<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 id="change-theme-ultra" v-if="themeSwitcher.isDarkTheme" class="ant-menu-theme-switch"
|
||||
@mousedown="themeSwitcher.animationsOffUltra()">
|
||||
|
|
@ -17,6 +17,12 @@
|
|||
<a-checkbox :style="{ marginLeft: '2px' }" :checked="themeSwitcher.isUltra"
|
||||
@click="themeSwitcher.toggleUltra()"></a-checkbox>
|
||||
</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-menu>
|
||||
</template>
|
||||
|
|
@ -26,13 +32,17 @@
|
|||
<template>
|
||||
<a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
|
||||
<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>
|
||||
</a-space>
|
||||
<a-space v-if="themeSwitcher.isDarkTheme" direction="horizontal" size="small">
|
||||
<a-checkbox :checked="themeSwitcher.isUltra" @click="themeSwitcher.toggleUltra()"></a-checkbox>
|
||||
<span>{{ i18n "menu.ultraDark" }}</span>
|
||||
</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>
|
||||
</template>
|
||||
{{end}}
|
||||
|
|
@ -40,10 +50,34 @@
|
|||
{{define "component/aThemeSwitch"}}
|
||||
<script>
|
||||
function createThemeSwitcher() {
|
||||
const isDarkTheme = localStorage.getItem('dark-mode') === 'true';
|
||||
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
let isDarkTheme = localStorage.getItem('dark-mode') === 'true';
|
||||
let isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
|
||||
// Glass Morphism включен по умолчанию, если не установлено явно
|
||||
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';
|
||||
document.querySelector('body').setAttribute('class', theme);
|
||||
|
|
@ -68,13 +102,33 @@
|
|||
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,
|
||||
isUltra,
|
||||
isGlassMorphism,
|
||||
get currentTheme() {
|
||||
return this.isDarkTheme ? 'dark' : 'light';
|
||||
},
|
||||
toggleTheme() {
|
||||
if (this.isGlassMorphism) {
|
||||
return; // Не позволяем включать темную тему когда включен Glass Morphism
|
||||
}
|
||||
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);
|
||||
document.querySelector('body').setAttribute('class', this.isDarkTheme ? 'dark' : 'light');
|
||||
document.getElementById('message').className = themeSwitcher.currentTheme;
|
||||
|
|
@ -87,6 +141,23 @@
|
|||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@
|
|||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</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 v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">
|
||||
|
|
|
|||
|
|
@ -31,6 +31,26 @@
|
|||
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
|
||||
</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>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
|
|
@ -41,7 +61,7 @@
|
|||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
{{define "form/shadowsocks"}}
|
||||
<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-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
<a-collapse v-if="isEdit">
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.shadowsockses.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
{{define "form/trojan"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" 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 v-if="isEdit">
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.trojans.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
{{define "form/vless"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" 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 v-if="isEdit">
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' +
|
||||
inbound.settings.vlesses.length">
|
||||
<table width="100%">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
{{define "form/vmess"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" 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 v-if="isEdit">
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vmesses.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
|
|
|
|||
397
web/html/hosts.html
Normal file
397
web/html/hosts.html
Normal 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" .}}
|
||||
|
|
@ -123,18 +123,6 @@
|
|||
<a-icon type="export"></a-icon>
|
||||
{{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }}
|
||||
</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-dropdown>
|
||||
</a-space>
|
||||
|
|
@ -204,18 +192,6 @@
|
|||
{{ i18n "qrCode" }}
|
||||
</a-menu-item>
|
||||
<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-icon type="export"></a-icon>
|
||||
{{ i18n "pages.inbounds.export"}}
|
||||
|
|
@ -224,10 +200,6 @@
|
|||
<a-icon type="export"></a-icon>
|
||||
{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}
|
||||
</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 v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
|
|
@ -239,9 +211,6 @@
|
|||
<a-icon type="copy"></a-icon>
|
||||
{{ i18n "pages.inbounds.exportInbound" }}
|
||||
</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-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}}
|
||||
</a-menu-item>
|
||||
|
|
@ -353,19 +322,12 @@
|
|||
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
|
||||
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
|
||||
</tr>
|
||||
<tr 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>
|
||||
<!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
|
||||
</table>
|
||||
</template>
|
||||
<a-tag
|
||||
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
|
||||
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
|
||||
<template v-if="dbInbound.total > 0">
|
||||
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>
|
||||
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
|
||||
<template v-if="false">
|
||||
<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"
|
||||
|
|
@ -375,9 +337,6 @@
|
|||
</a-tag>
|
||||
</a-popover>
|
||||
</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">
|
||||
<a-switch v-model="dbInbound.enable"
|
||||
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
|
||||
|
|
@ -516,21 +475,12 @@
|
|||
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
|
||||
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
|
||||
</tr>
|
||||
<tr
|
||||
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>
|
||||
<!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
|
||||
</table>
|
||||
</template>
|
||||
<a-tag
|
||||
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
|
||||
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
|
||||
<template v-if="dbInbound.total > 0">
|
||||
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>
|
||||
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
|
||||
<template v-if="false">
|
||||
<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"
|
||||
|
|
@ -563,6 +513,19 @@
|
|||
<a-tag color="blue">[[ dbInbound.trafficReset ]]</a-tag>
|
||||
</td>
|
||||
</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>
|
||||
</template>
|
||||
<a-badge>
|
||||
|
|
@ -574,13 +537,6 @@
|
|||
</a-badge>
|
||||
</a-popover>
|
||||
</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-space>
|
||||
</a-card>
|
||||
|
|
@ -650,11 +606,6 @@
|
|||
align: 'center',
|
||||
width: 90,
|
||||
scopedSlots: { customRender: 'traffic' },
|
||||
}, {
|
||||
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
|
||||
align: 'center',
|
||||
width: 60,
|
||||
scopedSlots: { customRender: 'allTimeInbound' },
|
||||
}, {
|
||||
title: '{{ i18n "pages.inbounds.expireDate" }}',
|
||||
align: 'center',
|
||||
|
|
@ -691,7 +642,6 @@
|
|||
{ title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
|
||||
{ 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.allTimeTraffic" }}', width: 60, align: 'center', scopedSlots: { customRender: 'allTime' } },
|
||||
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
|
||||
];
|
||||
|
||||
|
|
@ -706,6 +656,8 @@
|
|||
el: '#app',
|
||||
mixins: [MediaQueryMixin],
|
||||
data: {
|
||||
availableNodes: [],
|
||||
multiNodeMode: false,
|
||||
themeSwitcher,
|
||||
persianDatepicker,
|
||||
loadingStates: {
|
||||
|
|
@ -746,6 +698,44 @@
|
|||
loading(spinning = true) {
|
||||
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() {
|
||||
this.refreshing = true;
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
|
|
@ -804,6 +794,11 @@
|
|||
this.clientCount.splice(0);
|
||||
for (const inbound of dbInbounds) {
|
||||
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()
|
||||
this.inbounds.push(to_inbound);
|
||||
this.dbInbounds.push(dbInbound);
|
||||
|
|
@ -938,15 +933,6 @@
|
|||
case "subs":
|
||||
this.exportAllSubs();
|
||||
break;
|
||||
case "resetInbounds":
|
||||
this.resetAllTraffic();
|
||||
break;
|
||||
case "resetClients":
|
||||
this.resetAllClientTraffics(-1);
|
||||
break;
|
||||
case "delDepletedClients":
|
||||
this.delDepletedClients(-1)
|
||||
break;
|
||||
}
|
||||
},
|
||||
clickAction(action, dbInbound) {
|
||||
|
|
@ -960,12 +946,6 @@
|
|||
case "edit":
|
||||
this.openEditInbound(dbInbound.id);
|
||||
break;
|
||||
case "addClient":
|
||||
this.openAddClient(dbInbound.id)
|
||||
break;
|
||||
case "addBulkClient":
|
||||
this.openAddBulkClient(dbInbound.id)
|
||||
break;
|
||||
case "export":
|
||||
this.inboundLinks(dbInbound.id);
|
||||
break;
|
||||
|
|
@ -975,21 +955,12 @@
|
|||
case "clipboard":
|
||||
this.copy(dbInbound.id);
|
||||
break;
|
||||
case "resetTraffic":
|
||||
this.resetTraffic(dbInbound.id);
|
||||
break;
|
||||
case "resetClients":
|
||||
this.resetAllClientTraffics(dbInbound.id);
|
||||
break;
|
||||
case "clone":
|
||||
this.openCloneInbound(dbInbound);
|
||||
break;
|
||||
case "delete":
|
||||
this.delInbound(dbInbound.id);
|
||||
break;
|
||||
case "delDepletedClients":
|
||||
this.delDepletedClients(dbInbound.id)
|
||||
break;
|
||||
}
|
||||
},
|
||||
openCloneInbound(dbInbound) {
|
||||
|
|
@ -1041,6 +1012,20 @@
|
|||
openEditInbound(dbInboundId) {
|
||||
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||
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({
|
||||
title: '{{ i18n "pages.inbounds.modifyInbound"}}',
|
||||
okText: '{{ i18n "update"}}',
|
||||
|
|
@ -1075,6 +1060,14 @@
|
|||
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
||||
}
|
||||
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);
|
||||
},
|
||||
|
|
@ -1100,6 +1093,21 @@
|
|||
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
||||
}
|
||||
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);
|
||||
},
|
||||
|
|
@ -1252,6 +1260,10 @@
|
|||
},
|
||||
checkFallback(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("@")) {
|
||||
rootInbound = this.inbounds.find((i) =>
|
||||
i.isTcp &&
|
||||
|
|
@ -1312,7 +1324,10 @@
|
|||
async submit(url, data, modal) {
|
||||
const msg = await HttpUtil.postWithModal(url, data, modal);
|
||||
if (msg.success) {
|
||||
// Force reload inbounds to get updated nodeIds from server
|
||||
await this.getDBInbounds();
|
||||
// Force Vue to update the view
|
||||
this.$forceUpdate();
|
||||
}
|
||||
},
|
||||
getInboundClients(dbInbound) {
|
||||
|
|
@ -1581,7 +1596,8 @@
|
|||
this.searchInbounds(newVal);
|
||||
}, 500)
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
await this.loadMultiNodeMode();
|
||||
if (window.location.protocol !== "https:") {
|
||||
this.showAlert = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,19 @@
|
|||
</a-row>
|
||||
</a-card>
|
||||
</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-card hoverable>
|
||||
<template #title>
|
||||
|
|
@ -379,6 +392,15 @@
|
|||
</a-icon>
|
||||
</template>
|
||||
<a-form layout="inline">
|
||||
<a-form-item class="mr-05" v-if="multiNodeMode" label="Node:">
|
||||
<a-select size="small" v-model="xraylogModal.nodeId" :style="{ width: '180px' }" @change="openXrayLogs()"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme" placeholder="Select Node">
|
||||
<a-select-option value="">All Nodes</a-select-option>
|
||||
<a-select-option v-for="node in xraylogModal.nodes" :key="node.id" :value="node.id.toString()">
|
||||
[[ node.name || 'Node ' + node.id ]]
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item class="mr-05">
|
||||
<a-input-group compact>
|
||||
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
|
||||
|
|
@ -685,6 +707,7 @@
|
|||
this.appStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
|
||||
this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
|
||||
this.nodes = { online: 0, total: 0 };
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
|
|
@ -707,6 +730,11 @@
|
|||
this.appUptime = data.appUptime;
|
||||
this.appStats = data.appStats;
|
||||
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) {
|
||||
case 'running':
|
||||
this.xray.color = "green";
|
||||
|
|
@ -726,6 +754,24 @@
|
|||
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 = {
|
||||
|
|
@ -797,10 +843,14 @@
|
|||
visible: false,
|
||||
logs: [],
|
||||
rows: 20,
|
||||
filter: '',
|
||||
showDirect: true,
|
||||
showBlocked: true,
|
||||
showProxy: true,
|
||||
loading: false,
|
||||
multiNodeMode: false,
|
||||
nodes: [],
|
||||
nodeId: '',
|
||||
show(logs) {
|
||||
this.visible = true;
|
||||
this.logs = logs;
|
||||
|
|
@ -895,12 +945,43 @@
|
|||
showAlert: false,
|
||||
showIp: false,
|
||||
ipLimitEnable: false,
|
||||
multiNodeMode: false,
|
||||
},
|
||||
methods: {
|
||||
loading(spinning, tip = '{{ i18n "loading"}}') {
|
||||
this.loadingStates.spinning = spinning;
|
||||
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() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
|
|
@ -1027,12 +1108,45 @@
|
|||
logModal.loading = false;
|
||||
},
|
||||
async openXrayLogs() {
|
||||
xraylogModal.loading = true;
|
||||
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
|
||||
if (!msg.success) {
|
||||
return;
|
||||
// Ensure multi-node mode is loaded and nodes are available
|
||||
if (this.multiNodeMode && xraylogModal.nodes.length === 0) {
|
||||
await this.loadNodesForLogs();
|
||||
}
|
||||
|
||||
xraylogModal.loading = true;
|
||||
const params = {
|
||||
filter: xraylogModal.filter,
|
||||
showDirect: xraylogModal.showDirect,
|
||||
showBlocked: xraylogModal.showBlocked,
|
||||
showProxy: xraylogModal.showProxy
|
||||
};
|
||||
|
||||
// If multi-node mode and nodeId is selected, use node-specific endpoint
|
||||
if (this.multiNodeMode && xraylogModal.nodeId) {
|
||||
const msg = await HttpUtil.post('/panel/node/logs/' + xraylogModal.nodeId, {
|
||||
count: xraylogModal.rows,
|
||||
filter: xraylogModal.filter,
|
||||
showDirect: xraylogModal.showDirect,
|
||||
showBlocked: xraylogModal.showBlocked,
|
||||
showProxy: xraylogModal.showProxy
|
||||
});
|
||||
if (!msg.success) {
|
||||
xraylogModal.loading = false;
|
||||
return;
|
||||
}
|
||||
xraylogModal.show(msg.obj);
|
||||
} else {
|
||||
// Use standard endpoint with optional nodeId
|
||||
if (xraylogModal.nodeId) {
|
||||
params.nodeId = xraylogModal.nodeId;
|
||||
}
|
||||
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, params);
|
||||
if (!msg.success) {
|
||||
xraylogModal.loading = false;
|
||||
return;
|
||||
}
|
||||
xraylogModal.show(msg.obj);
|
||||
}
|
||||
xraylogModal.show(msg.obj);
|
||||
await PromiseUtil.sleep(500);
|
||||
xraylogModal.loading = false;
|
||||
},
|
||||
|
|
@ -1117,6 +1231,13 @@
|
|||
}, 2000);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'xraylogModal.visible'(newVal) {
|
||||
if (newVal && this.multiNodeMode && xraylogModal.nodes.length === 0) {
|
||||
this.loadNodesForLogs();
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (window.location.protocol !== "https:") {
|
||||
this.showAlert = true;
|
||||
|
|
@ -1127,6 +1248,9 @@
|
|||
this.ipLimitEnable = msg.obj.ipLimitEnable;
|
||||
}
|
||||
|
||||
// Load multi-node mode setting
|
||||
await this.loadMultiNodeMode();
|
||||
|
||||
// Initial status fetch
|
||||
await this.getStatus();
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@
|
|||
this.loadingStates.spinning = true;
|
||||
const msg = await HttpUtil.post('/login', this.user);
|
||||
if (msg.success) {
|
||||
// Устанавливаем флаг для показа popup "Что нового?" после логина
|
||||
sessionStorage.setItem('showWhatsNew', 'true');
|
||||
location.href = basePath + 'panel/';
|
||||
}
|
||||
this.loadingStates.spinning = false;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</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 label='{{ i18n "pages.client.delayedStart" }}'>
|
||||
<a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch>
|
||||
|
|
|
|||
307
web/html/modals/client_entity_modal.html
Normal file
307
web/html/modals/client_entity_modal.html
Normal 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}}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
{{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"
|
||||
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:class="themeSwitcher.currentTheme"
|
||||
|
|
@ -10,6 +15,8 @@
|
|||
</a-modal>
|
||||
<script>
|
||||
|
||||
// NOTE: This modal is for backward compatibility with old client architecture.
|
||||
// New clients should use clientEntityModal (see clients.html).
|
||||
const clientModal = {
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
|
|
|
|||
153
web/html/modals/host_modal.html
Normal file
153
web/html/modals/host_modal.html
Normal 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}}
|
||||
|
|
@ -23,6 +23,19 @@
|
|||
<a-tag>[[ dbInbound.port ]]</a-tag>
|
||||
</td>
|
||||
</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>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
|
|
@ -508,8 +521,17 @@
|
|||
clientIps: '',
|
||||
show(dbInbound, index) {
|
||||
this.index = index;
|
||||
this.inbound = dbInbound.toInbound();
|
||||
// Create DBInbound first to ensure nodeIds are properly processed
|
||||
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.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;
|
||||
|
|
@ -563,6 +585,12 @@
|
|||
get 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() {
|
||||
if (infoModal.clientStats) {
|
||||
return infoModal.clientStats.enable;
|
||||
|
|
@ -629,6 +657,10 @@
|
|||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
getNodeName(nodeId) {
|
||||
const node = this.availableNodes.find(n => n.id === nodeId);
|
||||
return node ? node.name : null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@
|
|||
show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) {
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
|
||||
if (inbound) {
|
||||
this.inbound = Inbound.fromJson(inbound.toJson());
|
||||
} else {
|
||||
this.inbound = new Inbound();
|
||||
}
|
||||
|
||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
||||
// This ensures Vue reactivity works properly
|
||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
||||
|
|
@ -35,14 +37,42 @@
|
|||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
}
|
||||
|
||||
if (dbInbound) {
|
||||
this.dbInbound = new DBInbound(dbInbound);
|
||||
} else {
|
||||
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.visible = true;
|
||||
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() {
|
||||
inModal.visible = false;
|
||||
|
|
@ -102,17 +132,14 @@
|
|||
get isEdit() {
|
||||
return inModal.isEdit;
|
||||
},
|
||||
get client() {
|
||||
return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
|
||||
},
|
||||
get datepicker() {
|
||||
return app.datepicker;
|
||||
},
|
||||
get delayedExpireDays() {
|
||||
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
|
||||
get multiNodeMode() {
|
||||
return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false;
|
||||
},
|
||||
set delayedExpireDays(days) {
|
||||
this.client.expiryTime = -86400000 * days;
|
||||
get availableNodes() {
|
||||
return app && app.availableNodes || [];
|
||||
},
|
||||
get externalProxy() {
|
||||
return this.inbound.stream.externalProxy.length > 0;
|
||||
|
|
|
|||
228
web/html/modals/node_modal.html
Normal file
228
web/html/modals/node_modal.html
Normal 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}}
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
</a-space>
|
||||
</template>
|
||||
<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">
|
||||
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}}</span></a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</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>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||
|
|
@ -110,7 +110,8 @@
|
|||
this.dbInbound = dbInbound;
|
||||
this.inbound = dbInbound.toInbound();
|
||||
this.client = client;
|
||||
this.subId = '';
|
||||
// Set subId from client if available
|
||||
this.subId = (client && client.subId) ? client.subId : '';
|
||||
this.qrcodes = [];
|
||||
// Reset the status fetched flag when showing the modal
|
||||
if (qrModalApp) qrModalApp.statusFetched = false;
|
||||
|
|
@ -124,12 +125,25 @@
|
|||
});
|
||||
});
|
||||
} 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({
|
||||
remark: l.remark,
|
||||
remark: displayRemark,
|
||||
link: l.link,
|
||||
useIPv4: false,
|
||||
originalLink: l.link
|
||||
originalLink: l.link,
|
||||
nodeId: l.nodeId
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -231,9 +245,15 @@
|
|||
});
|
||||
},
|
||||
genSubLink(subID) {
|
||||
if (!app || !app.subSettings || !app.subSettings.subURI) {
|
||||
return '';
|
||||
}
|
||||
return app.subSettings.subURI + subID;
|
||||
},
|
||||
genSubJsonLink(subID) {
|
||||
if (!app || !app.subSettings || !app.subSettings.subJsonURI) {
|
||||
return '';
|
||||
}
|
||||
return app.subSettings.subJsonURI + subID;
|
||||
},
|
||||
revertOverflow() {
|
||||
|
|
@ -261,9 +281,15 @@
|
|||
}
|
||||
if (qrModal.client && qrModal.client.subId) {
|
||||
qrModal.subId = qrModal.client.subId;
|
||||
this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId));
|
||||
if (app.subSettings.subJsonEnable) {
|
||||
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId));
|
||||
const subLink = this.genSubLink(qrModal.subId);
|
||||
if (subLink) {
|
||||
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) => {
|
||||
|
|
|
|||
613
web/html/nodes.html
Normal file
613
web/html/nodes.html
Normal 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" .}}
|
||||
|
|
@ -231,6 +231,44 @@
|
|||
sample = []
|
||||
this.remarkModel.forEach(r => sample.push(this.remarkModels[r]));
|
||||
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: {
|
||||
|
|
@ -271,7 +309,21 @@
|
|||
}
|
||||
|
||||
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();
|
||||
this.saveBtnDisable = true;
|
||||
}
|
||||
|
|
@ -292,7 +344,10 @@
|
|||
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
|
||||
this.loading(false);
|
||||
if (msg.success) {
|
||||
Vue.prototype.$message.success('{{ i18n "pages.settings.toasts.modifySettings" }}');
|
||||
await this.getAllSetting();
|
||||
} else {
|
||||
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.settings.toasts.getSettings" }}');
|
||||
}
|
||||
},
|
||||
async updateUser() {
|
||||
|
|
|
|||
|
|
@ -146,7 +146,33 @@
|
|||
</template>
|
||||
</a-setting-list-item>
|
||||
</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">
|
||||
<template #title>Enable LDAP sync</template>
|
||||
<template #control>
|
||||
|
|
|
|||
155
web/job/check_client_hwid_job.go
Normal file
155
web/job/check_client_hwid_job.go
Normal 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()
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
)
|
||||
|
||||
|
|
@ -33,6 +34,14 @@ func NewCheckClientIpJob() *CheckClientIpJob {
|
|||
}
|
||||
|
||||
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 {
|
||||
j.lastClear = time.Now().Unix()
|
||||
}
|
||||
|
|
|
|||
89
web/job/check_node_health_job.go
Normal file
89
web/job/check_node_health_job.go
Normal 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)
|
||||
}()
|
||||
}
|
||||
|
|
@ -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.
|
||||
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() {
|
||||
j.checkTime = 0
|
||||
} else {
|
||||
|
|
|
|||
31
web/job/collect_node_stats_job.go
Normal file
31
web/job/collect_node_stats_job.go
Normal 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
132
web/middleware/cache.go
Normal 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
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
342
web/service/client_hwid.go
Normal 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
|
||||
}
|
||||
320
web/service/client_traffic.go
Normal file
320
web/service/client_traffic.go
Normal 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
254
web/service/host.go
Normal 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
879
web/service/node.go
Normal 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(®isterResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse registration response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Node: %s] Successfully registered node with API key", node.Name)
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// UpdateNode updates an existing node.
|
||||
// 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
|
||||
}
|
||||
|
|
@ -92,6 +92,10 @@ type Status struct {
|
|||
Mem uint64 `json:"mem"`
|
||||
Uptime uint64 `json:"uptime"`
|
||||
} `json:"appStats"`
|
||||
Nodes struct {
|
||||
Online int `json:"online"`
|
||||
Total int `json:"total"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
|
||||
// Release represents information about a software release from GitHub.
|
||||
|
|
@ -414,6 +418,32 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -763,7 +793,8 @@ func (s *ServerService) GetXrayLogs(
|
|||
showBlocked string,
|
||||
showProxy string,
|
||||
freedoms []string,
|
||||
blackholes []string) []LogEntry {
|
||||
blackholes []string,
|
||||
nodeId string) []LogEntry {
|
||||
|
||||
const (
|
||||
Direct = iota
|
||||
|
|
@ -774,6 +805,76 @@ func (s *ServerService) GetXrayLogs(
|
|||
countInt, _ := strconv.Atoi(count)
|
||||
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()
|
||||
if err != nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/random"
|
||||
"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/xray"
|
||||
)
|
||||
|
|
@ -94,6 +95,10 @@ var defaultValueMap = map[string]string{
|
|||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "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.
|
||||
|
|
@ -110,78 +115,85 @@ func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
|||
}
|
||||
|
||||
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
||||
db := database.GetDB()
|
||||
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)
|
||||
var allSetting *entity.AllSetting
|
||||
|
||||
err := cache.GetOrSet(cache.KeySettingsAll, &allSetting, cache.TTLSettings, func() (interface{}, error) {
|
||||
// Cache miss - fetch from database
|
||||
db := database.GetDB()
|
||||
settings := make([]*model.Setting, 0)
|
||||
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)
|
||||
|
||||
setSetting := func(key, value string) (err error) {
|
||||
defer func() {
|
||||
panicErr := recover()
|
||||
if panicErr != nil {
|
||||
err = errors.New(fmt.Sprint(panicErr))
|
||||
}
|
||||
}()
|
||||
setSetting := func(key, value string) (err error) {
|
||||
defer func() {
|
||||
panicErr := recover()
|
||||
if panicErr != nil {
|
||||
err = errors.New(fmt.Sprint(panicErr))
|
||||
}
|
||||
}()
|
||||
|
||||
var found bool
|
||||
var field reflect.StructField
|
||||
for _, f := range fields {
|
||||
if f.Tag.Get("json") == key {
|
||||
field = f
|
||||
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 {
|
||||
// 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)
|
||||
keyMap := map[string]bool{}
|
||||
for _, setting := range settings {
|
||||
err := setSetting(setting.Key, setting.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, 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)
|
||||
keyMap[setting.Key] = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
keyMap := map[string]bool{}
|
||||
for _, setting := range settings {
|
||||
err := setSetting(setting.Key, setting.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for key, value := range defaultValueMap {
|
||||
if keyMap[key] {
|
||||
continue
|
||||
}
|
||||
err := setSetting(key, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keyMap[setting.Key] = true
|
||||
}
|
||||
|
||||
for key, value := range defaultValueMap {
|
||||
if keyMap[key] {
|
||||
continue
|
||||
}
|
||||
err := setSetting(key, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return allSetting, nil
|
||||
return result, nil
|
||||
})
|
||||
|
||||
return allSetting, err
|
||||
}
|
||||
|
||||
func (s *SettingService) ResetSettings() error {
|
||||
|
|
@ -195,29 +207,54 @@ func (s *SettingService) ResetSettings() error {
|
|||
}
|
||||
|
||||
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
||||
db := database.GetDB()
|
||||
setting := &model.Setting{}
|
||||
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return setting, nil
|
||||
cacheKey := cache.KeySettingPrefix + key
|
||||
var setting *model.Setting
|
||||
|
||||
err := cache.GetOrSet(cacheKey, &setting, cache.TTLSetting, func() (interface{}, error) {
|
||||
// Cache miss - fetch from database
|
||||
db := database.GetDB()
|
||||
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 {
|
||||
setting, err := s.getSetting(key)
|
||||
db := database.GetDB()
|
||||
if database.IsNotFound(err) {
|
||||
return db.Create(&model.Setting{
|
||||
err = db.Create(&model.Setting{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}).Error
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
setting.Key = key
|
||||
setting.Value = value
|
||||
err = db.Save(setting).Error
|
||||
}
|
||||
setting.Key = key
|
||||
setting.Value = value
|
||||
return db.Save(setting).Error
|
||||
|
||||
if err == nil {
|
||||
// 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) {
|
||||
|
|
@ -564,11 +601,26 @@ func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
|
|||
}
|
||||
|
||||
func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
||||
accessLogPath, err := xray.GetAccessLogPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return (accessLogPath != "none" && accessLogPath != ""), nil
|
||||
// Cache key for this computed setting
|
||||
cacheKey := "computed:ipLimitEnable"
|
||||
var result bool
|
||||
|
||||
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
|
||||
|
|
@ -652,6 +704,50 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
|
|||
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 {
|
||||
if err := allSetting.CheckValid(); err != nil {
|
||||
return err
|
||||
|
|
@ -683,32 +779,44 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
|||
}
|
||||
|
||||
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
type settingFunc func() (any, error)
|
||||
settings := map[string]settingFunc{
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"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)
|
||||
|
||||
for key, fn := range settings {
|
||||
value, err := fn()
|
||||
if err != nil {
|
||||
return "", err
|
||||
// Cache key includes host to support multi-domain setups
|
||||
cacheKey := fmt.Sprintf("defaultSettings:%s", host)
|
||||
var result map[string]any
|
||||
|
||||
err := cache.GetOrSet(cacheKey, &result, cache.TTLSettings, func() (interface{}, error) {
|
||||
// Cache miss - compute default settings
|
||||
type settingFunc func() (any, error)
|
||||
settings := map[string]settingFunc{
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -3554,18 +3554,24 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
|||
logger.Error("Error in opening db file for backup: ", err)
|
||||
}
|
||||
|
||||
file, err = os.Open(xray.GetConfigPath())
|
||||
if err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading config.json: ", err)
|
||||
// Check if multi-node mode is enabled before trying to open config.json
|
||||
multiMode, err := t.settingService.GetMultiNodeMode()
|
||||
if err == nil && !multiMode {
|
||||
file, err = os.Open(xray.GetConfigPath())
|
||||
if err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, 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 {
|
||||
logger.Error("Error in opening config.json file for backup: ", err)
|
||||
} else if multiMode {
|
||||
logger.Debug("Skipping config.json backup in multi-node mode")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ package service
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
|
||||
|
|
@ -22,9 +24,11 @@ var (
|
|||
|
||||
// XrayService provides business logic for Xray process management.
|
||||
// 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 {
|
||||
inboundService InboundService
|
||||
settingService SettingService
|
||||
nodeService NodeService
|
||||
xrayAPI xray.XrayAPI
|
||||
}
|
||||
|
||||
|
|
@ -211,12 +215,24 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
|||
}
|
||||
|
||||
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
|
||||
// In multi-node mode, it sends configurations to nodes instead of restarting local Xray.
|
||||
func (s *XrayService) RestartXray(isForce bool) error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
logger.Debug("restart Xray, force:", isForce)
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -240,6 +256,167 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
|||
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.
|
||||
func (s *XrayService) StopXray() error {
|
||||
lock.Lock()
|
||||
|
|
|
|||
|
|
@ -23,11 +23,17 @@
|
|||
"indefinite" = "Indefinite"
|
||||
"unlimited" = "Unlimited"
|
||||
"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"
|
||||
"info" = "More Information"
|
||||
"edit" = "Edit"
|
||||
"delete" = "Delete"
|
||||
"reset" = "Reset"
|
||||
"refresh" = "Refresh"
|
||||
"noData" = "No data."
|
||||
"copySuccess" = "Copied Successful"
|
||||
"sure" = "Sure"
|
||||
|
|
@ -71,6 +77,8 @@
|
|||
"emptyBalancersDesc" = "No added balancers."
|
||||
"emptyReverseDesc" = "No added reverse proxies."
|
||||
"somethingWentWrong" = "Something went wrong"
|
||||
"active" = "Active"
|
||||
"inactive" = "Inactive"
|
||||
|
||||
[subscription]
|
||||
"title" = "Subscription info"
|
||||
|
|
@ -86,17 +94,6 @@
|
|||
"unlimited" = "Unlimited"
|
||||
"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]
|
||||
"hello" = "Hello"
|
||||
"title" = "Welcome"
|
||||
|
|
@ -117,6 +114,7 @@
|
|||
"swap" = "Swap"
|
||||
"storage" = "Storage"
|
||||
"memory" = "RAM"
|
||||
"nodesAvailability" = "Nodes Availability"
|
||||
"threads" = "Threads"
|
||||
"xrayStatus" = "Xray"
|
||||
"stopXray" = "Stop"
|
||||
|
|
@ -407,6 +405,17 @@
|
|||
"muxDesc" = "Transmit multiple independent data streams within an established data stream."
|
||||
"muxSett" = "Mux Settings"
|
||||
"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."
|
||||
"notifications" = "Notifications"
|
||||
"certs" = "Certificaties"
|
||||
|
|
@ -582,6 +591,211 @@
|
|||
"twoFactorModalDeleteSuccess" = "Two-factor authentication has been successfully deleted"
|
||||
"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]
|
||||
"modifySettings" = "The parameters have been changed."
|
||||
"getSettings" = "An error occurred while retrieving parameters."
|
||||
|
|
|
|||
|
|
@ -23,11 +23,17 @@
|
|||
"indefinite" = "Бесконечно"
|
||||
"unlimited" = "Безлимит"
|
||||
"none" = "Пусто"
|
||||
"hwidSettings" = "Настройки HWID"
|
||||
"hwidEnabled" = "Включить ограничение по HWID"
|
||||
"maxHwid" = "Максимум устройств (HWID)"
|
||||
"hwidBetaWarningTitle" = "Бета-функция"
|
||||
"hwidBetaWarningDesc" = "Отслеживание HWID находится в бета-версии и работает только с клиентами happ и v2raytun. Другие клиенты могут не поддерживать регистрацию HWID."
|
||||
"qrCode" = "QR-код"
|
||||
"info" = "Информация"
|
||||
"edit" = "Изменить"
|
||||
"delete" = "Удалить"
|
||||
"reset" = "Сбросить"
|
||||
"refresh" = "Обновить"
|
||||
"noData" = "Нет данных."
|
||||
"copySuccess" = "Скопировано"
|
||||
"sure" = "Да"
|
||||
|
|
@ -71,6 +77,8 @@
|
|||
"emptyBalancersDesc" = "Нет добавленных балансировщиков."
|
||||
"emptyReverseDesc" = "Нет добавленных реверс-прокси."
|
||||
"somethingWentWrong" = "Что-то пошло не так"
|
||||
"active" = "Активен"
|
||||
"inactive" = "Неактивен"
|
||||
|
||||
[subscription]
|
||||
"title" = "Информация о подписке"
|
||||
|
|
@ -86,17 +94,6 @@
|
|||
"unlimited" = "Неограниченно"
|
||||
"noExpiry" = "Бессрочно"
|
||||
|
||||
[menu]
|
||||
"theme" = "Тема"
|
||||
"dark" = "Темная"
|
||||
"ultraDark" = "Очень темная"
|
||||
"dashboard" = "Дашборд"
|
||||
"inbounds" = "Подключения"
|
||||
"settings" = "Настройки"
|
||||
"xray" = "Настройки Xray"
|
||||
"logout" = "Выход"
|
||||
"link" = "Управление"
|
||||
|
||||
[pages.login]
|
||||
"hello" = "Привет!"
|
||||
"title" = "Добро пожаловать!"
|
||||
|
|
@ -117,6 +114,7 @@
|
|||
"swap" = "Файл подкачки"
|
||||
"storage" = "Диск"
|
||||
"memory" = "ОЗУ"
|
||||
"nodesAvailability" = "Доступность нод"
|
||||
"threads" = "Потоки"
|
||||
"xrayStatus" = "Xray"
|
||||
"stopXray" = "Остановить"
|
||||
|
|
@ -408,6 +406,17 @@
|
|||
"muxSett" = "Настройки Mux"
|
||||
"direct" = "Прямое подключение"
|
||||
"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" = "Уведомления"
|
||||
"certs" = "Сертификаты"
|
||||
"externalTraffic" = "Внешний трафик"
|
||||
|
|
@ -582,6 +591,211 @@
|
|||
"twoFactorModalDeleteSuccess" = "Двухфакторная аутентификация была успешно удалена"
|
||||
"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]
|
||||
"modifySettings" = "Настройки изменены"
|
||||
"getSettings" = "Произошла ошибка при получении параметров."
|
||||
|
|
|
|||
44
web/web.go
44
web/web.go
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/cache"
|
||||
"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/"})))
|
||||
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)
|
||||
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil {
|
||||
store.Options(sessions.Options{
|
||||
|
|
@ -220,7 +233,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
engine.Use(func(c *gin.Context) {
|
||||
uri := c.Request.RequestURI
|
||||
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,12 +332,15 @@ func (s *Server) startTask() {
|
|||
|
||||
go func() {
|
||||
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
|
||||
s.cron.AddJob("@every 10s", job.NewXrayTrafficJob())
|
||||
// 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 3s", job.NewXrayTrafficJob())
|
||||
}()
|
||||
|
||||
// check client ips from log file every 10 sec
|
||||
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
|
||||
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
|
|
@ -343,6 +364,11 @@ func (s *Server) startTask() {
|
|||
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
|
||||
var entry cron.EntryID
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
|
|
@ -490,3 +516,13 @@ func (s *Server) GetCron() *cron.Cron {
|
|||
func (s *Server) GetWSHub() any {
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
package websocket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
|
|
@ -21,6 +22,7 @@ const (
|
|||
MessageTypeNotification MessageType = "notification" // System notification
|
||||
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
||||
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
||||
MessageTypeNodes MessageType = "nodes" // Nodes list update
|
||||
)
|
||||
|
||||
// Message represents a WebSocket message
|
||||
|
|
@ -62,6 +64,15 @@ type Hub struct {
|
|||
// Worker pool for parallel broadcasting
|
||||
workerPoolSize int
|
||||
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
|
||||
|
|
@ -85,6 +96,9 @@ func NewHub() *Hub {
|
|||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
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
|
||||
}
|
||||
|
||||
// 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{
|
||||
Type: messageType,
|
||||
Payload: payload,
|
||||
Time: getCurrentTimestamp(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
if err := enc.Encode(msg); err != nil {
|
||||
logger.Error("Failed to marshal WebSocket message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove trailing newline from Encode
|
||||
data := bytes.TrimRight(buf.Bytes(), "\n")
|
||||
|
||||
// Limit message size to prevent memory issues
|
||||
const maxMessageSize = 1024 * 1024 // 1MB
|
||||
if len(data) > maxMessageSize {
|
||||
|
|
@ -278,6 +311,14 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
|
|||
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
|
||||
select {
|
||||
case h.broadcast <- data:
|
||||
|
|
@ -298,18 +339,25 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
|
|||
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{
|
||||
Type: messageType,
|
||||
Payload: payload,
|
||||
Time: getCurrentTimestamp(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
if err := enc.Encode(msg); err != nil {
|
||||
logger.Error("Failed to marshal WebSocket message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove trailing newline from Encode
|
||||
data := bytes.TrimRight(buf.Bytes(), "\n")
|
||||
|
||||
// Limit message size to prevent memory issues
|
||||
const maxMessageSize = 1024 * 1024 // 1MB
|
||||
if len(data) > maxMessageSize {
|
||||
|
|
@ -317,6 +365,14 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
|
|||
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()
|
||||
// Filter clients by topics and quickly release lock
|
||||
subscribedClients := make([]*Client, 0)
|
||||
|
|
|
|||
|
|
@ -80,3 +80,11 @@ func BroadcastXrayState(state string, errorMsg string) {
|
|||
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastNodes broadcasts nodes list update to all connected clients
|
||||
func BroadcastNodes(nodes any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeNodes, nodes)
|
||||
}
|
||||
}
|
||||
18
xray/api.go
18
xray/api.go
|
|
@ -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.
|
||||
// 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) {
|
||||
isInbound := matches[1] == "inbound"
|
||||
tag := matches[2]
|
||||
|
|
@ -259,14 +263,19 @@ func processTraffic(matches []string, value int64, trafficMap map[string]*Traffi
|
|||
trafficMap[tag] = traffic
|
||||
}
|
||||
|
||||
// Direct mapping: downlink → Down, uplink → Up
|
||||
if isDown {
|
||||
traffic.Down = value
|
||||
traffic.Down = value // downlink = traffic from clients to server
|
||||
} else {
|
||||
traffic.Up = value
|
||||
traffic.Up = value // uplink = traffic from server to clients
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
email := matches[1]
|
||||
isDown := matches[2] == "downlink"
|
||||
|
|
@ -277,10 +286,11 @@ func processClientTraffic(matches []string, value int64, clientTrafficMap map[st
|
|||
clientTrafficMap[email] = traffic
|
||||
}
|
||||
|
||||
// Direct mapping: downlink → Down, uplink → Up (consistent with processTraffic)
|
||||
if isDown {
|
||||
traffic.Down = value
|
||||
traffic.Down = value // downlink = traffic from client to server
|
||||
} else {
|
||||
traffic.Up = value
|
||||
traffic.Up = value // uplink = traffic from server to client
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,9 +69,15 @@ func GetAccessPersistentPrevLogPath() string {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
config, err := os.ReadFile(GetConfigPath())
|
||||
configPath := GetConfigPath()
|
||||
config, err := os.ReadFile(configPath)
|
||||
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)
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue