Merge pull request #1 from konstpic/3x-new

feat: 3X-UI(3.0.0) Multi-Node Architecture, Client Management, HWID Tracking, Host Management, Glass Morphism UI & Redis Cache
This commit is contained in:
konstpic 2026-01-12 11:11:26 +03:00 committed by GitHub
commit 49062c983a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 10309 additions and 1344 deletions

View file

@ -40,6 +40,11 @@ func initModels() error {
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.Node{}, &model.Node{},
&model.InboundNodeMapping{}, &model.InboundNodeMapping{},
&model.ClientEntity{},
&model.ClientInboundMapping{},
&model.Host{},
&model.HostInboundMapping{},
&model.ClientHWID{}, // HWID tracking for clients
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {

View file

@ -104,6 +104,8 @@ type Setting struct {
} }
// Client represents a client configuration for Xray inbounds with traffic limits and settings. // Client represents a client configuration for Xray inbounds with traffic limits and settings.
// This is a legacy struct used for JSON parsing from inbound Settings.
// For database operations, use ClientEntity instead.
type Client struct { type Client struct {
ID string `json:"id"` // Unique client identifier ID string `json:"id"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
@ -122,14 +124,56 @@ type Client struct {
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
} }
// ClientEntity represents a client as a separate database entity.
// Clients can be assigned to multiple inbounds.
type ClientEntity struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
UserId int `json:"userId" gorm:"index"` // Associated user ID
Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_user_email"` // Client email identifier (unique per user)
UUID string `json:"uuid" form:"uuid"` // UUID/ID for VMESS/VLESS
Security string `json:"security" form:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks)
Flow string `json:"flow" form:"flow"` // Flow control (XTLS)
LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client
TotalGB float64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB (supports decimal values like 0.01 for MB)
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
Status string `json:"status" form:"status" gorm:"default:active"` // Client status: active, expired_traffic, expired_time
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
// Relations (not stored in DB, loaded via joins)
InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to
// Traffic statistics (stored directly in ClientEntity table)
Up int64 `json:"up,omitempty" form:"-" gorm:"default:0"` // Upload traffic in bytes
Down int64 `json:"down,omitempty" form:"-" gorm:"default:0"` // Download traffic in bytes
AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"default:0"` // All-time traffic usage
LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"default:0"` // Last online timestamp
// HWID (Hardware ID) restrictions
HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client
MaxHWID int `json:"maxHwid" form:"maxHwid" gorm:"column:max_hwid;default:1"` // Maximum number of allowed HWID devices (0 = unlimited)
HWIDs []*ClientHWID `json:"hwids,omitempty" form:"-" gorm:"-"` // Registered HWIDs for this client (loaded from client_hwids table, not stored in ClientEntity table)
}
// Node represents a worker node in multi-node architecture. // Node represents a worker node in multi-node architecture.
type Node struct { type Node struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
Name string `json:"name" form:"name"` // Node name/identifier Name string `json:"name" form:"name"` // Node name/identifier
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080") Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...")
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
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 CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
} }
@ -140,3 +184,68 @@ type InboundNodeMapping struct {
InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID 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 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"
}

5
go.mod
View file

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

10
go.sum
View file

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

View file

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

View file

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

View file

@ -6,9 +6,12 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config"
nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs"
"github.com/mhsanaei/3x-ui/v2/node/xray" "github.com/mhsanaei/3x-ui/v2/node/xray"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -40,6 +43,9 @@ func (s *Server) Start() error {
// Health check endpoint (no auth required) // Health check endpoint (no auth required)
router.GET("/health", s.health) router.GET("/health", s.health)
// Registration endpoint (no auth required, used for initial setup)
router.POST("/api/v1/register", s.register)
// API endpoints (require auth) // API endpoints (require auth)
api := router.Group("/api/v1") api := router.Group("/api/v1")
{ {
@ -48,6 +54,8 @@ func (s *Server) Start() error {
api.POST("/force-reload", s.forceReload) api.POST("/force-reload", s.forceReload)
api.GET("/status", s.status) api.GET("/status", s.status)
api.GET("/stats", s.stats) api.GET("/stats", s.stats)
api.GET("/logs", s.getLogs)
api.GET("/service-logs", s.getServiceLogs)
} }
s.httpServer = &http.Server{ s.httpServer = &http.Server{
@ -72,8 +80,8 @@ func (s *Server) Stop() error {
// authMiddleware validates API key from Authorization header. // authMiddleware validates API key from Authorization header.
func (s *Server) authMiddleware() gin.HandlerFunc { func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Skip auth for health endpoint // Skip auth for health and registration endpoints
if c.Request.URL.Path == "/health" { if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" {
c.Next() c.Next()
return return
} }
@ -117,12 +125,26 @@ func (s *Server) applyConfig(c *gin.Context) {
return return
} }
// Validate JSON // 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 var configJSON json.RawMessage
if err := json.Unmarshal(body, &configJSON); err != nil { if err := json.Unmarshal(body, &configJSON); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return return
} }
}
if err := s.xrayManager.ApplyConfig(body); err != nil { if err := s.xrayManager.ApplyConfig(body); err != nil {
logger.Errorf("Failed to apply config: %v", err) logger.Errorf("Failed to apply config: %v", err)
@ -175,3 +197,107 @@ func (s *Server) stats(c *gin.Context) {
c.JSON(http.StatusOK, stats) c.JSON(http.StatusOK, stats)
} }
// getLogs returns XRAY access logs from the node.
func (s *Server) getLogs(c *gin.Context) {
// Get query parameters
countStr := c.DefaultQuery("count", "100")
filter := c.DefaultQuery("filter", "")
count, err := strconv.Atoi(countStr)
if err != nil || count < 1 || count > 10000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"})
return
}
logs, err := s.xrayManager.GetLogs(count, filter)
if err != nil {
logger.Errorf("Failed to get logs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
// getServiceLogs returns service application logs from the node (node service logs and XRAY core logs).
func (s *Server) getServiceLogs(c *gin.Context) {
// Get query parameters
countStr := c.DefaultQuery("count", "100")
level := c.DefaultQuery("level", "debug")
count, err := strconv.Atoi(countStr)
if err != nil || count < 1 || count > 10000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"})
return
}
// Get logs from logger buffer
logs := logger.GetLogs(count, level)
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
// register handles node registration from the panel.
// This endpoint receives an API key from the panel and saves it persistently.
// No authentication required - this is the initial setup step.
func (s *Server) register(c *gin.Context) {
type RegisterRequest struct {
ApiKey string `json:"apiKey" binding:"required"` // API key generated by panel
PanelURL string `json:"panelUrl,omitempty"` // Panel URL (optional)
NodeAddress string `json:"nodeAddress,omitempty"` // Node address (optional)
}
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Check if node is already registered
existingConfig := nodeConfig.GetConfig()
if existingConfig.ApiKey != "" {
logger.Warningf("Node is already registered. Rejecting registration attempt to prevent overwriting existing API key")
c.JSON(http.StatusConflict, gin.H{
"error": "Node is already registered. API key cannot be overwritten",
"message": "This node has already been registered. If you need to re-register, please remove the node-config.json file first",
})
return
}
// Save API key to config file (only if not already registered)
if err := nodeConfig.SetApiKey(req.ApiKey, false); err != nil {
logger.Errorf("Failed to save API key: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save API key: " + err.Error()})
return
}
// Update API key in server (for immediate use)
s.apiKey = req.ApiKey
// Save panel URL if provided
if req.PanelURL != "" {
if err := nodeConfig.SetPanelURL(req.PanelURL); err != nil {
logger.Warningf("Failed to save panel URL: %v", err)
} else {
// Update log pusher with new panel URL and API key
nodeLogs.SetPanelURL(req.PanelURL)
nodeLogs.UpdateApiKey(req.ApiKey) // Update API key in log pusher
}
} else {
// Even if panel URL is not provided, update API key in log pusher
nodeLogs.UpdateApiKey(req.ApiKey)
}
// Save node address if provided
if req.NodeAddress != "" {
if err := nodeConfig.SetNodeAddress(req.NodeAddress); err != nil {
logger.Warningf("Failed to save node address: %v", err)
}
}
logger.Infof("Node registered successfully with API key (length: %d)", len(req.ApiKey))
c.JSON(http.StatusOK, gin.H{
"message": "Node registered successfully",
"apiKey": req.ApiKey, // Return API key for confirmation
})
}

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

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

View file

@ -7,7 +7,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} # - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key}
- NODE_API_KEY=test-key #- NODE_API_KEY=test-key
- PANEL_URL=http://192.168.0.7:2054
ports: ports:
- "8080:8080" - "8080:8080"
- "44000:44000" - "44000:44000"
@ -18,7 +19,46 @@ services:
# If the file doesn't exist, it will be created when XRAY config is first applied # If the file doesn't exist, it will be created when XRAY config is first applied
networks: networks:
- xray-network - 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: networks:
xray-network: xray-network:
driver: bridge driver: bridge

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

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

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

View file

@ -75,6 +75,12 @@ class AllSetting {
// Multi-node mode settings // Multi-node mode settings
this.multiNodeMode = false; // Multi-node mode setting this.multiNodeMode = false; // Multi-node mode setting
// HWID tracking mode
// "off" = HWID tracking disabled
// "client_header" = HWID provided by client via x-hwid header (default, recommended)
// "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only)
this.hwidMode = "client_header"; // HWID tracking mode
if (data == null) { if (data == null) {
return return
} }
@ -90,6 +96,18 @@ class AllSetting {
} else { } else {
this.multiNodeMode = false; this.multiNodeMode = false;
} }
// Ensure hwidMode is valid string (default to "client_header" if invalid)
if (this.hwidMode === undefined || this.hwidMode === null) {
this.hwidMode = "client_header";
} else if (typeof this.hwidMode !== 'string') {
this.hwidMode = String(this.hwidMode);
}
// Validate hwidMode value
const validHwidModes = ["off", "client_header", "legacy_fingerprint"];
if (!validHwidModes.includes(this.hwidMode)) {
this.hwidMode = "client_header"; // Default to client_header if invalid
}
} }
equals(other) { equals(other) {

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

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

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

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -30,10 +30,17 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
g.GET("/nodes", a.nodes) g.GET("/nodes", a.nodes)
g.GET("/clients", a.clients)
g.GET("/hosts", a.hosts)
a.settingController = NewSettingController(g) a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g) a.xraySettingController = NewXraySettingController(g)
a.nodeController = NewNodeController(g.Group("/node")) a.nodeController = NewNodeController(g.Group("/node"))
// Register client and host controllers directly under /panel (not /panel/api)
NewClientController(g.Group("/client"))
NewHostController(g.Group("/host"))
NewClientHWIDController(g.Group("/client")) // Register HWID controller under /panel/client/hwid
} }
// index renders the main panel index page. // index renders the main panel index page.
@ -60,3 +67,13 @@ func (a *XUIController) xraySettings(c *gin.Context) {
func (a *XUIController) nodes(c *gin.Context) { func (a *XUIController) nodes(c *gin.Context) {
html(c, "nodes.html", "pages.nodes.title", nil) html(c, "nodes.html", "pages.nodes.title", nil)
} }
// clients renders the clients management page.
func (a *XUIController) clients(c *gin.Context) {
html(c, "clients.html", "pages.clients.title", nil)
}
// hosts renders the hosts management page (multi-node mode).
func (a *XUIController) hosts(c *gin.Context) {
html(c, "hosts.html", "pages.hosts.title", nil)
}

View file

@ -101,6 +101,12 @@ type AllSetting struct {
// Multi-node mode setting // Multi-node mode setting
MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode
// HWID tracking mode
// "off" = HWID tracking disabled
// "client_header" = HWID provided by client via x-hwid header (default, recommended)
// "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only)
HwidMode string `json:"hwidMode" form:"hwidMode"` // HWID tracking mode
// JSON subscription routing rules // JSON subscription routing rules
} }
@ -171,5 +177,15 @@ func (s *AllSetting) CheckValid() error {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)
} }
// Validate HWID mode
validHwidModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if s.HwidMode != "" && !validHwidModes[s.HwidMode] {
return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", s.HwidMode)
}
return nil return nil
} }

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

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

View file

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

View file

@ -6,7 +6,7 @@
<a-theme-switch></a-theme-switch> <a-theme-switch></a-theme-switch>
<a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab" <a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab"
@click="({key}) => openLink(key)"> @click="({key}) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key"> <a-menu-item v-for="tab in tabs" :key="tab.key" :data-menu-key="tab.key">
<a-icon :type="tab.icon"></a-icon> <a-icon :type="tab.icon"></a-icon>
<span v-text="tab.title"></span> <span v-text="tab.title"></span>
</a-menu-item> </a-menu-item>
@ -20,7 +20,7 @@
<a-theme-switch></a-theme-switch> <a-theme-switch></a-theme-switch>
<a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab" <a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab"
@click="({key}) => openLink(key)"> @click="({key}) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key"> <a-menu-item v-for="tab in tabs" :key="tab.key" :data-menu-key="tab.key">
<a-icon :type="tab.icon"></a-icon> <a-icon :type="tab.icon"></a-icon>
<span v-text="tab.title"></span> <span v-text="tab.title"></span>
</a-menu-item> </a-menu-item>
@ -39,6 +39,8 @@
<script> <script>
const SIDEBAR_COLLAPSED_KEY = "isSidebarCollapsed" const SIDEBAR_COLLAPSED_KEY = "isSidebarCollapsed"
// Get multiNodeMode from server-rendered template
const INITIAL_MULTI_NODE_MODE = {{ if .multiNodeMode }}true{{else}}false{{end}};
Vue.component('a-sidebar', { Vue.component('a-sidebar', {
data() { data() {
@ -49,7 +51,7 @@
], ],
visible: false, visible: false,
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)), collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
multiNodeMode: false multiNodeMode: INITIAL_MULTI_NODE_MODE
} }
}, },
methods: { methods: {
@ -76,6 +78,11 @@
icon: 'user', icon: 'user',
title: '{{ i18n "menu.inbounds"}}' title: '{{ i18n "menu.inbounds"}}'
}, },
{
key: '{{ .base_path }}panel/clients',
icon: 'team',
title: '{{ i18n "menu.clients"}}'
},
{ {
key: '{{ .base_path }}panel/settings', key: '{{ .base_path }}panel/settings',
icon: 'setting', icon: 'setting',
@ -88,13 +95,18 @@
} }
]; ];
// Add Nodes menu item if multi-node mode is enabled // Add Nodes and Hosts menu items if multi-node mode is enabled
if (this.multiNodeMode) { if (this.multiNodeMode) {
this.tabs.splice(3, 0, { this.tabs.splice(4, 0, {
key: '{{ .base_path }}panel/nodes', key: '{{ .base_path }}panel/nodes',
icon: 'cluster', icon: 'cluster',
title: '{{ i18n "menu.nodes"}}' title: '{{ i18n "menu.nodes"}}'
}); });
this.tabs.splice(5, 0, {
key: '{{ .base_path }}panel/hosts',
icon: 'cloud-server',
title: '{{ i18n "menu.hosts"}}'
});
} }
this.tabs.push({ this.tabs.push({
@ -124,8 +136,7 @@
}, },
mounted() { mounted() {
this.updateTabs(); this.updateTabs();
this.loadMultiNodeMode(); // Watch for multi-node mode changes (update tabs if mode changes)
// Watch for multi-node mode changes
setInterval(() => { setInterval(() => {
this.loadMultiNodeMode(); this.loadMultiNodeMode();
}, 5000); }, 5000);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -123,18 +123,6 @@
<a-icon type="export"></a-icon> <a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }} {{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="resetInbounds">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetAllTraffic" }}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@ -204,18 +192,6 @@
{{ i18n "qrCode" }} {{ i18n "qrCode" }}
</a-menu-item> </a-menu-item>
<template v-if="dbInbound.isMultiUser()"> <template v-if="dbInbound.isMultiUser()">
<a-menu-item key="addClient">
<a-icon type="user-add"></a-icon>
{{ i18n "pages.client.add"}}
</a-menu-item>
<a-menu-item key="addBulkClient">
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetInboundClientTraffics"}}
</a-menu-item>
<a-menu-item key="export"> <a-menu-item key="export">
<a-icon type="export"></a-icon> <a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} {{ i18n "pages.inbounds.export"}}
@ -224,10 +200,6 @@
<a-icon type="export"></a-icon> <a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }} {{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</template> </template>
<template v-else> <template v-else>
<a-menu-item key="showInfo"> <a-menu-item key="showInfo">
@ -239,9 +211,6 @@
<a-icon type="copy"></a-icon> <a-icon type="copy"></a-icon>
{{ i18n "pages.inbounds.exportInbound" }} {{ i18n "pages.inbounds.exportInbound" }}
</a-menu-item> </a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="retweet"></a-icon> {{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item key="clone"> <a-menu-item key="clone">
<a-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}} <a-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}}
</a-menu-item> </a-menu-item>
@ -353,19 +322,12 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td> <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td> <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr> </tr>
<tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total"> <!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td>
</tr>
</table> </table>
</template> </template>
<a-tag <a-tag>
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)"> [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / <template v-if="false">
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
@ -375,9 +337,6 @@
</a-tag> </a-tag>
</a-popover> </a-popover>
</template> </template>
<template slot="allTimeInbound" slot-scope="text, dbInbound">
<a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag>
</template>
<template slot="enable" slot-scope="text, dbInbound"> <template slot="enable" slot-scope="text, dbInbound">
<a-switch v-model="dbInbound.enable" <a-switch v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch> @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
@ -516,21 +475,12 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td> <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td> <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr> </tr>
<tr <!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down)
]]</td>
</tr>
</table> </table>
</template> </template>
<a-tag <a-tag>
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)"> [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / <template v-if="false">
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path <path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
@ -587,13 +537,6 @@
</a-badge> </a-badge>
</a-popover> </a-popover>
</template> </template>
<template slot="expandedRowRender" slot-scope="record">
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable"}}
</a-table>
</template>
</a-table> </a-table>
</a-space> </a-space>
</a-card> </a-card>
@ -663,11 +606,6 @@
align: 'center', align: 'center',
width: 90, width: 90,
scopedSlots: { customRender: 'traffic' }, scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'allTimeInbound' },
}, { }, {
title: '{{ i18n "pages.inbounds.expireDate" }}', title: '{{ i18n "pages.inbounds.expireDate" }}',
align: 'center', align: 'center',
@ -704,7 +642,6 @@
{ title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } }, { title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } }, { title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } }, { title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 60, align: 'center', scopedSlots: { customRender: 'allTime' } },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } }, { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
]; ];
@ -996,15 +933,6 @@
case "subs": case "subs":
this.exportAllSubs(); this.exportAllSubs();
break; break;
case "resetInbounds":
this.resetAllTraffic();
break;
case "resetClients":
this.resetAllClientTraffics(-1);
break;
case "delDepletedClients":
this.delDepletedClients(-1)
break;
} }
}, },
clickAction(action, dbInbound) { clickAction(action, dbInbound) {
@ -1018,12 +946,6 @@
case "edit": case "edit":
this.openEditInbound(dbInbound.id); this.openEditInbound(dbInbound.id);
break; break;
case "addClient":
this.openAddClient(dbInbound.id)
break;
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
case "export": case "export":
this.inboundLinks(dbInbound.id); this.inboundLinks(dbInbound.id);
break; break;
@ -1033,21 +955,12 @@
case "clipboard": case "clipboard":
this.copy(dbInbound.id); this.copy(dbInbound.id);
break; break;
case "resetTraffic":
this.resetTraffic(dbInbound.id);
break;
case "resetClients":
this.resetAllClientTraffics(dbInbound.id);
break;
case "clone": case "clone":
this.openCloneInbound(dbInbound); this.openCloneInbound(dbInbound);
break; break;
case "delete": case "delete":
this.delInbound(dbInbound.id); this.delInbound(dbInbound.id);
break; break;
case "delDepletedClients":
this.delDepletedClients(dbInbound.id)
break;
} }
}, },
openCloneInbound(dbInbound) { openCloneInbound(dbInbound) {

View file

@ -392,6 +392,15 @@
</a-icon> </a-icon>
</template> </template>
<a-form layout="inline"> <a-form layout="inline">
<a-form-item class="mr-05" v-if="multiNodeMode" label="Node:">
<a-select size="small" v-model="xraylogModal.nodeId" :style="{ width: '180px' }" @change="openXrayLogs()"
:dropdown-class-name="themeSwitcher.currentTheme" placeholder="Select Node">
<a-select-option value="">All Nodes</a-select-option>
<a-select-option v-for="node in xraylogModal.nodes" :key="node.id" :value="node.id.toString()">
[[ node.name || 'Node ' + node.id ]]
</a-select-option>
</a-select>
</a-form-item>
<a-form-item class="mr-05"> <a-form-item class="mr-05">
<a-input-group compact> <a-input-group compact>
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()" <a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
@ -834,10 +843,14 @@
visible: false, visible: false,
logs: [], logs: [],
rows: 20, rows: 20,
filter: '',
showDirect: true, showDirect: true,
showBlocked: true, showBlocked: true,
showProxy: true, showProxy: true,
loading: false, loading: false,
multiNodeMode: false,
nodes: [],
nodeId: '',
show(logs) { show(logs) {
this.visible = true; this.visible = true;
this.logs = logs; this.logs = logs;
@ -944,11 +957,31 @@
const msg = await HttpUtil.post("/panel/setting/all"); const msg = await HttpUtil.post("/panel/setting/all");
if (msg && msg.success && msg.obj) { if (msg && msg.success && msg.obj) {
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false; this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
xraylogModal.multiNodeMode = this.multiNodeMode;
// Load nodes if multi-node mode is enabled
if (this.multiNodeMode) {
await this.loadNodesForLogs();
}
} }
} catch (e) { } catch (e) {
console.warn("Failed to load multi-node mode:", e); console.warn("Failed to load multi-node mode:", e);
} }
}, },
async loadNodesForLogs() {
try {
const msg = await HttpUtil.get("/panel/node/list");
if (msg && msg.success && msg.obj) {
xraylogModal.nodes = msg.obj.map(node => ({
id: node.id,
name: node.name || 'Node ' + node.id,
address: node.address || '',
status: node.status || 'unknown'
}));
}
} catch (e) {
console.warn("Failed to load nodes for logs:", e);
}
},
async getStatus() { async getStatus() {
try { try {
const msg = await HttpUtil.get('/panel/api/server/status'); const msg = await HttpUtil.get('/panel/api/server/status');
@ -1075,12 +1108,45 @@
logModal.loading = false; logModal.loading = false;
}, },
async openXrayLogs() { async openXrayLogs() {
// Ensure multi-node mode is loaded and nodes are available
if (this.multiNodeMode && xraylogModal.nodes.length === 0) {
await this.loadNodesForLogs();
}
xraylogModal.loading = true; 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 }); 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) { if (!msg.success) {
xraylogModal.loading = false;
return; return;
} }
xraylogModal.show(msg.obj); 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);
}
await PromiseUtil.sleep(500); await PromiseUtil.sleep(500);
xraylogModal.loading = false; xraylogModal.loading = false;
}, },
@ -1165,6 +1231,13 @@
}, 2000); }, 2000);
}, },
}, },
watch: {
'xraylogModal.visible'(newVal) {
if (newVal && this.multiNodeMode && xraylogModal.nodes.length === 0) {
this.loadNodesForLogs();
}
}
},
async mounted() { async mounted() {
if (window.location.protocol !== "https:") { if (window.location.protocol !== "https:") {
this.showAlert = true; this.showAlert = true;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -132,9 +132,6 @@
get isEdit() { get isEdit() {
return inModal.isEdit; return inModal.isEdit;
}, },
get client() {
return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
},
get datepicker() { get datepicker() {
return app.datepicker; return app.datepicker;
}, },
@ -144,12 +141,6 @@
get availableNodes() { get availableNodes() {
return app && app.availableNodes || []; return app && app.availableNodes || [];
}, },
get delayedExpireDays() {
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
},
set delayedExpireDays(days) {
this.client.expiryTime = -86400000 * days;
},
get externalProxy() { get externalProxy() {
return this.inbound.stream.externalProxy.length > 0; return this.inbound.stream.externalProxy.length > 0;
}, },

View file

@ -1,232 +1,228 @@
{{define "modals/nodeModal"}} {{define "modals/nodeModal"}}
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title" :confirm-loading="nodeModal.confirmLoading" <a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :class="themeSwitcher.currentTheme" :ok-text="nodeModal.okText" :width="600"> @ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600"
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> :confirm-loading="nodeModal.registering" :ok-button-props="{ disabled: nodeModal.registering }">
<a-form-item :label='{{ i18n "pages.nodes.nodeName" }}'> <div v-if="!nodeModal.registering && !nodeModal.showProgress">
<a-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-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
</a-form-item> </a-form-item>
<a-form-item :label='{{ i18n "pages.nodes.nodeAddress" }}'> <a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
<a-input v-model.trim="nodeModal.formData.address" placeholder="http://192.168.1.100:8080"></a-input> <a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
<div style="margin-top: 4px; color: #999; font-size: 12px;">
{{ i18n "pages.nodes.fullUrlHint" }}
</div>
</a-form-item> </a-form-item>
<a-form-item :label='{{ i18n "pages.nodes.nodeApiKey" }}'> <a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder="{{ i18n "pages.nodes.enterApiKey" }}"></a-input> <a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
<div style="margin-top: 4px; color: #999; font-size: 12px;">
{{ i18n "pages.nodes.apiKeyHint" }}
</div>
</a-form-item> </a-form-item>
<!-- API key is now auto-generated during registration, no need for user input -->
</a-form> </a-form>
</div>
<!-- Progress animation during registration -->
<div v-if="nodeModal.showProgress" style="padding: 20px 0; text-align: center;">
<a-steps :current="nodeModal.currentStep" direction="vertical" size="small">
<a-step title='{{ i18n "pages.nodes.connecting" }}' :status="nodeModal.steps.connecting">
<template slot="description">
<a-spin v-if="nodeModal.steps.connecting === 'process'" size="small" style="margin-right: 8px;"></a-spin>
<span v-if="nodeModal.steps.connecting === 'finish'">✓ {{ i18n "pages.nodes.connectionEstablished" }}</span>
<span v-if="nodeModal.steps.connecting === 'error'">✗ {{ i18n "pages.nodes.connectionError" }}</span>
</template>
</a-step>
<a-step title='{{ i18n "pages.nodes.generatingApiKey" }}' :status="nodeModal.steps.generating">
<template slot="description">
<a-spin v-if="nodeModal.steps.generating === 'process'" size="small" style="margin-right: 8px;"></a-spin>
<span v-if="nodeModal.steps.generating === 'finish'">✓ {{ i18n "pages.nodes.apiKeyGenerated" }}</span>
<span v-if="nodeModal.steps.generating === 'error'">✗ {{ i18n "pages.nodes.generationError" }}</span>
</template>
</a-step>
<a-step title='{{ i18n "pages.nodes.registeringNode" }}' :status="nodeModal.steps.registering">
<template slot="description">
<a-spin v-if="nodeModal.steps.registering === 'process'" size="small" style="margin-right: 8px;"></a-spin>
<span v-if="nodeModal.steps.registering === 'finish'">✓ {{ i18n "pages.nodes.nodeRegistered" }}</span>
<span v-if="nodeModal.steps.registering === 'error'">✗ {{ i18n "pages.nodes.registrationError" }}</span>
</template>
</a-step>
<a-step title='{{ i18n "pages.nodes.done" }}' :status="nodeModal.steps.completed">
<template slot="description">
<span v-if="nodeModal.steps.completed === 'finish'" style="color: #52c41a; font-weight: bold;">✓ {{ i18n "pages.nodes.nodeAddedSuccessfully" }}</span>
</template>
</a-step>
</a-steps>
</div>
</a-modal> </a-modal>
<script> <script>
// Make nodeModal globally available to ensure it works with any base path
const nodeModal = window.nodeModal = { const nodeModal = window.nodeModal = {
visible: false, visible: false,
title: '', title: '',
confirmLoading: false, okText: 'OK',
okText: '{{ i18n "sure" }}', registering: false,
isEdit: false, showProgress: false,
currentNode: null, currentStep: 0,
confirm: null, steps: {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
},
formData: { formData: {
name: '', name: '',
address: '', address: '',
apiKey: '' port: 8080
// apiKey is now auto-generated during registration
}, },
ok() { ok() {
// Validate form data // Валидация полей - используем nodeModal напрямую для правильного контекста
if (!this.formData.name || !this.formData.name.trim()) { 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" }}'); Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
}
return; return;
} }
if (!this.formData.address || !this.formData.address.trim()) {
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" }}'); Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
return;
} }
if (!/^https?:\/\/.+/.test(this.formData.address)) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.validUrl" }}');
return;
}
if (!this.formData.apiKey || !this.formData.apiKey.trim()) {
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
return; return;
} }
this.confirmLoading = true; // API key is now auto-generated during registration, no validation needed
if (this.confirm) {
const result = this.confirm({ ...this.formData }); // Если все поля заполнены, формируем полный адрес с портом
// If confirm returns a promise, handle it const dataToSend = { ...nodeModal.formData };
if (result && typeof result.then === 'function') {
result.catch(() => { // Всегда добавляем порт к адресу
this.confirmLoading = false; 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 { } else {
// If not async, reset loading after a short delay // Если не удалось распарсить, просто добавляем порт
setTimeout(() => { fullAddress = `${fullAddress}:${port}`;
this.confirmLoading = false;
}, 100);
} }
} else {
this.confirmLoading = false; // Удаляем порт из данных, так как он теперь в адресе
delete dataToSend.port;
dataToSend.address = fullAddress;
// Если это режим редактирования, просто вызываем confirm
if (nodeModal.isEdit) {
if (nodeModal.confirm) {
nodeModal.confirm(dataToSend);
}
nodeModal.visible = false;
return;
} }
},
show({ title = '', okText = '{{ i18n "sure" }}', node = null, confirm = (data) => { }, isEdit = false }) {
console.log('[nodeModal.show] START - called with:', { title, okText, node, isEdit });
console.log('[nodeModal.show] this.visible before:', this.visible);
console.log('[nodeModal.show] nodeModalVueInstance:', nodeModalVueInstance);
// Update properties using 'this' like in inbound_modal // Для добавления новой ноды показываем прогресс регистрации
this.title = title; nodeModal.registering = true;
this.okText = okText; nodeModal.showProgress = true;
this.isEdit = isEdit; nodeModal.currentStep = 0;
this.confirm = confirm;
console.log('[nodeModal.show] Properties updated:', { title: this.title, okText: this.okText, isEdit: this.isEdit });
if (node) { // Сброс всех шагов
this.currentNode = node; nodeModal.steps = {
this.formData = { connecting: 'wait',
name: node.name || '', generating: 'wait',
address: node.address || '', registering: 'wait',
apiKey: node.apiKey || '' completed: 'wait'
}; };
console.log('[nodeModal.show] Node data set:', this.formData);
} else {
this.currentNode = null;
this.formData = {
name: '',
address: '',
apiKey: ''
};
console.log('[nodeModal.show] Form data reset (new node)');
}
// Set visible - Vue will track this since nodeModal is in Vue instance data // Вызываем confirm с объединенным адресом (это запустит регистрацию)
console.log('[nodeModal.show] Setting this.visible = true'); if (nodeModal.confirm) {
this.visible = true; nodeModal.confirm(dataToSend);
console.log('[nodeModal.show] this.visible after setting:', this.visible);
// Check Vue instance
if (nodeModalVueInstance) {
console.log('[nodeModal.show] Vue instance exists');
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal:', nodeModalVueInstance.nodeModal);
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
console.log('[nodeModal.show] nodeModalVueInstance.$el:', nodeModalVueInstance.$el);
} else {
console.warn('[nodeModal.show] WARNING - Vue instance does not exist!');
} }
// Check DOM element
const modalElement = document.getElementById('node-modal');
console.log('[nodeModal.show] Modal element in DOM:', modalElement);
if (modalElement) {
console.log('[nodeModal.show] Modal element classes:', modalElement.className);
console.log('[nodeModal.show] Modal element style.display:', modalElement.style.display);
const computedStyle = window.getComputedStyle(modalElement);
console.log('[nodeModal.show] Modal element computed display:', computedStyle.display);
console.log('[nodeModal.show] Modal element computed visibility:', computedStyle.visibility);
console.log('[nodeModal.show] Modal element computed opacity:', computedStyle.opacity);
console.log('[nodeModal.show] Modal element computed z-index:', computedStyle.zIndex);
// Check for Ant Design modal root
const modalRoot = document.querySelector('.ant-modal-root');
console.log('[nodeModal.show] Ant Design modal root exists:', !!modalRoot);
if (modalRoot) {
console.log('[nodeModal.show] Modal root style.display:', window.getComputedStyle(modalRoot).display);
const modalWrap = modalRoot.querySelector('.ant-modal-wrap');
console.log('[nodeModal.show] Modal wrap exists:', !!modalWrap);
if (modalWrap) {
console.log('[nodeModal.show] Modal wrap style.display:', window.getComputedStyle(modalWrap).display);
const modalInWrap = modalWrap.querySelector('#node-modal');
console.log('[nodeModal.show] Modal #node-modal in wrap:', !!modalInWrap);
}
}
} else {
console.error('[nodeModal.show] ERROR - Modal element #node-modal not found in DOM!');
}
// Use nextTick to check after Vue updates
if (nodeModalVueInstance) {
nodeModalVueInstance.$nextTick(() => {
console.log('[nodeModal.show] After $nextTick - nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
const modalElementAfter = document.getElementById('node-modal');
if (modalElementAfter) {
const modalRootAfter = document.querySelector('.ant-modal-root');
if (modalRootAfter) {
console.log('[nodeModal.show] After $nextTick - Modal root display:', window.getComputedStyle(modalRootAfter).display);
}
}
});
}
console.log('[nodeModal.show] END');
}, },
cancel() { cancel() {
nodeModal.visible = false; this.visible = false;
// Reset form data this.resetProgress();
nodeModal.formData = { },
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: '', name: '',
address: '', address: '',
apiKey: '' port: 8080
// apiKey is auto-generated during registration
}; };
}
this.visible = true;
}, },
close() { close() {
nodeModal.visible = false; this.visible = false;
nodeModal.confirmLoading = false; this.resetProgress();
}, },
loading(loading = true) { resetProgress() {
this.confirmLoading = loading; this.registering = false;
this.showProgress = false;
this.currentStep = 0;
this.steps = {
connecting: 'wait',
generating: 'wait',
registering: 'wait',
completed: 'wait'
};
} }
}; };
// Store Vue instance globally to ensure methods are always accessible const nodeModalVueInstance = new Vue({
let nodeModalVueInstance = null;
// Create Vue instance after main app is ready
window.initNodeModalVue = function initNodeModalVue() {
if (nodeModalVueInstance) {
return; // Already initialized
}
const modalElement = document.getElementById('node-modal');
if (!modalElement) {
setTimeout(initNodeModalVue, 50);
return;
}
try {
nodeModalVueInstance = new Vue({
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
el: '#node-modal', el: '#node-modal',
data: { data: {
nodeModal: nodeModal, nodeModal: nodeModal
get themeSwitcher() {
// Try to get themeSwitcher from window or global scope
if (typeof window !== 'undefined' && window.themeSwitcher) {
return window.themeSwitcher;
}
if (typeof themeSwitcher !== 'undefined') {
return themeSwitcher;
}
// Fallback to a simple object if themeSwitcher is not available
return { currentTheme: 'light' };
}
} }
}); });
window.nodeModalVueInstance = nodeModalVueInstance;
} catch (error) {
console.error('[nodeModal init] ERROR creating Vue instance:', error);
}
};
// Wait for DOM and main app to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(window.initNodeModalVue, 100);
});
} else {
setTimeout(window.initNodeModalVue, 100);
}
</script> </script>
{{end}} {{end}}

View file

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

View file

@ -6,18 +6,14 @@
<a-sidebar></a-sidebar> <a-sidebar></a-sidebar>
<a-layout id="content-layout"> <a-layout id="content-layout">
<a-layout-content :style="{ padding: '24px 16px' }"> <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-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
<a-col> <a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable> <a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>{{ i18n "pages.nodes.title" }}</h2> <h2>{{ i18n "pages.nodes.title" }}</h2>
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<h3>{{ i18n "pages.nodes.addNewNode" }}</h3> <a-button type="primary" icon="plus" @click="openAddNode">{{ i18n "pages.nodes.addNewNode" }}</a-button>
<a-input id="node-name" placeholder='{{ i18n "pages.nodes.nodeName" }}' style="width: 200px; margin-right: 10px;"></a-input>
<a-input id="node-address" placeholder='{{ i18n "pages.nodes.nodeAddress" }} (http://192.168.1.100)' style="width: 250px; margin-right: 10px;"></a-input>
<a-input id="node-port" placeholder='{{ i18n "pages.nodes.nodePort" }} (8080)' type="number" style="width: 120px; margin-right: 10px;"></a-input>
<a-input-password id="node-apikey" placeholder='{{ i18n "pages.nodes.nodeApiKey" }}' style="width: 200px; margin-right: 10px;"></a-input-password>
<a-button type="primary" onclick="addNode()">{{ i18n "pages.nodes.addNode" }}</a-button>
</div> </div>
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
@ -62,6 +58,15 @@
[[ node.status || 'unknown' ]] [[ node.status || 'unknown' ]]
</a-tag> </a-tag>
</template> </template>
<template slot="responseTime" slot-scope="text, node">
<span v-if="node.responseTime && node.responseTime > 0" :style="{
color: node.responseTime < 100 ? '#52c41a' : node.responseTime < 300 ? '#faad14' : '#ff4d4f',
fontWeight: 'bold'
}">
[[ node.responseTime ]] ms
</span>
<span v-else style="color: #999;">-</span>
</template>
<template slot="inbounds" slot-scope="text, node"> <template slot="inbounds" slot-scope="text, node">
<template v-if="node.inbounds && node.inbounds.length > 0"> <template v-if="node.inbounds && node.inbounds.length > 0">
<a-tag v-for="(inbound, index) in node.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }"> <a-tag v-for="(inbound, index) in node.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
@ -100,6 +105,7 @@
<a-spin tip='{{ i18n "loading" }}'></a-spin> <a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card> </a-card>
</a-row> </a-row>
</transition>
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
</a-layout> </a-layout>
@ -109,6 +115,7 @@
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script> <script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}} {{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}} {{template "component/aThemeSwitch" .}}
{{template "modals/nodeModal"}}
<script> <script>
const columns = [{ const columns = [{
title: "ID", title: "ID",
@ -137,6 +144,11 @@
align: 'center', align: 'center',
width: 80, width: 80,
scopedSlots: { customRender: 'status' }, scopedSlots: { customRender: 'status' },
}, {
title: '{{ i18n "pages.nodes.responseTime" }}',
align: 'center',
width: 100,
scopedSlots: { customRender: 'responseTime' },
}, { }, {
title: '{{ i18n "pages.nodes.assignedInbounds" }}', title: '{{ i18n "pages.nodes.assignedInbounds" }}',
align: 'left', align: 'left',
@ -184,6 +196,7 @@
reloadingAll: false, reloadingAll: false,
editingNodeId: null, editingNodeId: null,
editingNodeName: '', editingNodeName: '',
pollInterval: null,
}, },
methods: { methods: {
async loadNodes() { async loadNodes() {
@ -196,6 +209,7 @@
name: node.name || '', name: node.name || '',
address: node.address || '', address: node.address || '',
status: node.status || 'unknown', status: node.status || 'unknown',
responseTime: node.responseTime || 0,
inbounds: node.inbounds || [] inbounds: node.inbounds || []
})); }));
} }
@ -228,7 +242,7 @@
this.reloadNode(node.id); this.reloadNode(node.id);
break; break;
case 'edit': case 'edit':
this.startEditNodeName(node); this.editNode(node);
break; break;
case 'delete': case 'delete':
this.deleteNode(node.id); this.deleteNode(node.id);
@ -388,79 +402,212 @@
} finally { } finally {
this.reloadingAll = false; 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() { async mounted() {
await this.loadNodes(); await this.loadNodes();
// Setup WebSocket for real-time updates
if (window.wsClient) {
window.wsClient.connect();
// Listen for nodes updates
window.wsClient.on('nodes', (payload) => {
if (payload && Array.isArray(payload)) {
this.nodes = payload.map(node => ({
id: node.id,
name: node.name || '',
address: node.address || '',
status: node.status || 'unknown',
responseTime: node.responseTime || 0,
inbounds: node.inbounds || []
}));
} }
}); });
async function addNode() { // Fallback to polling if WebSocket fails
const name = document.getElementById('node-name').value.trim(); window.wsClient.on('error', () => {
const address = document.getElementById('node-address').value.trim(); console.warn('WebSocket connection failed, falling back to polling');
const port = document.getElementById('node-port').value.trim(); this.startPolling();
const apiKey = document.getElementById('node-apikey').value;
if (!name || !address || !port || !apiKey) {
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
return;
}
// Validate address format
if (!address.match(/^https?:\/\//)) {
app.$message.error('{{ i18n "pages.nodes.validUrl" }}');
return;
}
// Validate port
const portNum = parseInt(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
app.$message.error('{{ i18n "pages.nodes.validPort" }}');
return;
}
// Construct full address
const fullAddress = `${address}:${port}`;
// Check for duplicate nodes
const existingNodes = app.$data.nodes || [];
const duplicate = existingNodes.find(node => {
try {
const nodeUrl = new URL(node.address);
const newUrl = new URL(fullAddress);
// Compare protocol, hostname, and port
const nodePort = nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80');
const newPort = newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80');
return nodeUrl.protocol === newUrl.protocol &&
nodeUrl.hostname === newUrl.hostname &&
nodePort === newPort;
} catch (e) {
// If URL parsing fails, do simple string comparison
return node.address === fullAddress;
}
}); });
if (duplicate) { window.wsClient.on('disconnected', () => {
app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}'); if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
return; console.warn('WebSocket reconnection failed, falling back to polling');
this.startPolling();
} }
});
try {
const msg = await HttpUtil.post('/panel/node/add', { name, address: fullAddress, apiKey });
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
document.getElementById('node-name').value = '';
document.getElementById('node-address').value = '';
document.getElementById('node-port').value = '';
document.getElementById('node-apikey').value = '';
app.loadNodes();
} else { } else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.addError" }}'); // Fallback to polling if WebSocket is not available
} this.startPolling();
} catch (error) {
console.error('Error:', error);
app.$message.error('{{ i18n "pages.nodes.addError" }}');
} }
} }
});
</script> </script>
{{template "page/body_end" .}} {{template "page/body_end" .}}

View file

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

View file

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

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

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

1282
web/service/client.go Normal file

File diff suppressed because it is too large Load diff

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

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

View file

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

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

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

File diff suppressed because it is too large Load diff

View file

@ -3,16 +3,20 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls"
"crypto/x509"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
@ -44,6 +48,71 @@ func (s *NodeService) AddNode(node *model.Node) error {
return db.Create(node).Error return db.Create(node).Error
} }
// RegisterNode registers a node by sending it an API key generated by the panel.
// This method generates a unique API key, sends it to the node, and returns the key.
func (s *NodeService) RegisterNode(node *model.Node) (string, error) {
// Generate a unique API key (32 characters, alphanumeric)
apiKey := random.Seq(32)
// Determine panel URL to send to node
settingService := SettingService{}
protocol := "http"
if certFile, _ := settingService.GetCertFile(); certFile != "" {
protocol = "https"
}
listenIP, _ := settingService.GetListen()
listenPort, _ := settingService.GetPort()
basePath, _ := settingService.GetBasePath()
panelURL := fmt.Sprintf("%s://%s:%d%s", protocol, listenIP, listenPort, basePath)
// Prepare registration request
registerData := map[string]interface{}{
"apiKey": apiKey,
"panelUrl": panelURL,
"nodeAddress": node.Address,
}
// Send registration request to node
client, err := s.createHTTPClient(node, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create HTTP client: %w", err)
}
registerURL := fmt.Sprintf("%s/api/v1/register", node.Address)
jsonData, err := json.Marshal(registerData)
if err != nil {
return "", fmt.Errorf("failed to marshal registration data: %w", err)
}
req, err := http.NewRequest("POST", registerURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create registration request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to register node: %w (check if node is accessible at %s)", err, node.Address)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("node registration failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse response to verify registration
var registerResp map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
return "", fmt.Errorf("failed to parse registration response: %w", err)
}
logger.Infof("[Node: %s] Successfully registered node with API key", node.Name)
return apiKey, nil
}
// UpdateNode updates an existing node. // UpdateNode updates an existing node.
// Only updates fields that are provided (non-empty for strings, non-zero for integers). // Only updates fields that are provided (non-empty for strings, non-zero for integers).
func (s *NodeService) UpdateNode(node *model.Node) error { func (s *NodeService) UpdateNode(node *model.Node) error {
@ -70,11 +139,28 @@ func (s *NodeService) UpdateNode(node *model.Node) error {
updates["api_key"] = node.ApiKey updates["api_key"] = node.ApiKey
} }
// Update status and last_check if provided (these are usually set by health checks, not user edits) // 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 { if node.Status != "" && node.Status != existingNode.Status {
updates["status"] = node.Status updates["status"] = node.Status
} }
if node.ResponseTime > 0 && node.ResponseTime != existingNode.ResponseTime {
updates["response_time"] = node.ResponseTime
} else if node.ResponseTime == 0 && existingNode.ResponseTime > 0 {
// Allow resetting to 0 (e.g., on error)
updates["response_time"] = 0
}
if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck { if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck {
updates["last_check"] = node.LastCheck updates["last_check"] = node.LastCheck
} }
@ -103,37 +189,95 @@ func (s *NodeService) DeleteNode(id int) error {
return db.Delete(&model.Node{}, id).Error return db.Delete(&model.Node{}, id).Error
} }
// CheckNodeHealth checks if a node is online and updates its status. // CheckNodeHealth checks if a node is online and updates its status and response time.
func (s *NodeService) CheckNodeHealth(node *model.Node) error { func (s *NodeService) CheckNodeHealth(node *model.Node) error {
status, err := s.CheckNodeStatus(node) status, responseTime, err := s.CheckNodeStatus(node)
if err != nil { if err != nil {
node.Status = "error" node.Status = "error"
node.ResponseTime = 0 // Set to 0 on error
node.LastCheck = time.Now().Unix() node.LastCheck = time.Now().Unix()
s.UpdateNode(node) if updateErr := s.UpdateNode(node); updateErr != nil {
logger.Errorf("[Node: %s] Failed to update node status: %v", node.Name, updateErr)
}
return err return err
} }
node.Status = status node.Status = status
node.ResponseTime = responseTime
node.LastCheck = time.Now().Unix() node.LastCheck = time.Now().Unix()
return s.UpdateNode(node) logger.Debugf("[Node: %s] Health check: status=%s, responseTime=%d ms", node.Name, status, responseTime)
if updateErr := s.UpdateNode(node); updateErr != nil {
logger.Errorf("[Node: %s] Failed to update node with response time: %v", node.Name, updateErr)
return updateErr
}
return nil
} }
// CheckNodeStatus performs a health check on a given node. // createHTTPClient creates an HTTP client configured for the node's TLS settings.
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) { func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration) (*http.Client, error) {
client := &http.Client{ transport := &http.Transport{
Timeout: 5 * time.Second, 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) url := fmt.Sprintf("%s/health", node.Address)
// Measure response time
startTime := time.Now()
resp, err := client.Get(url) resp, err := client.Get(url)
responseTime := time.Since(startTime).Milliseconds()
if err != nil { if err != nil {
return "offline", err return "offline", 0, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
return "online", nil return "online", responseTime, nil
} }
return "error", fmt.Errorf("node returned status code %d", resp.StatusCode) return "error", 0, fmt.Errorf("node returned status code %d", resp.StatusCode)
} }
// CheckAllNodesHealth checks health of all nodes. // CheckAllNodesHealth checks health of all nodes.
@ -226,8 +370,9 @@ type NodeClientTraffic struct {
// GetNodeStats retrieves traffic and online clients statistics from a node. // GetNodeStats retrieves traffic and online clients statistics from a node.
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) { func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
client := &http.Client{ client, err := s.createHTTPClient(node, 10*time.Second)
Timeout: 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) url := fmt.Sprintf("%s/api/v1/stats", node.Address)
@ -326,10 +471,10 @@ func (s *NodeService) CollectNodeStats() error {
strings.Contains(errMsg, "status code 404") || strings.Contains(errMsg, "status code 404") ||
strings.Contains(errMsg, "status code 500") { strings.Contains(errMsg, "status code 500") {
// These are expected errors, log as debug only // These are expected errors, log as debug only
logger.Debugf("Skipping stats collection from node %s (ID: %d): %v", result.node.Name, result.node.Id, result.err) logger.Debugf("[Node: %s] Skipping stats collection: %v", result.node.Name, result.err)
} else { } else {
// Unexpected errors should be logged as warning // Unexpected errors should be logged as warning
logger.Warningf("Failed to get stats from node %s (ID: %d): %v", result.node.Name, result.node.Id, result.err) logger.Warningf("[Node: %s] Failed to get stats: %v", result.node.Name, result.err)
} }
continue continue
} }
@ -419,12 +564,29 @@ func (s *NodeService) UnassignInboundFromNode(inboundId int) error {
// ApplyConfigToNode sends XRAY configuration to a node. // ApplyConfigToNode sends XRAY configuration to a node.
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error { func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 30*time.Second)
Timeout: 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) url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(xrayConfig)) req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON))
if err != nil { if err != nil {
return err return err
} }
@ -446,10 +608,59 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err
return nil return nil
} }
// getPanelURL constructs the panel URL from settings.
func (s *NodeService) getPanelURL() string {
settingService := SettingService{}
// Get panel settings
webListen, _ := settingService.GetListen()
webPort, _ := settingService.GetPort()
webDomain, _ := settingService.GetWebDomain()
webCertFile, _ := settingService.GetCertFile()
webKeyFile, _ := settingService.GetKeyFile()
webBasePath, _ := settingService.GetBasePath()
// Determine protocol
protocol := "http"
if webCertFile != "" || webKeyFile != "" {
protocol = "https"
}
// Determine host
host := webDomain
if host == "" {
host = webListen
if host == "" {
// If no listen IP specified, use localhost (node should be able to reach panel)
host = "127.0.0.1"
}
}
// Construct URL
url := fmt.Sprintf("%s://%s", protocol, host)
if webPort > 0 && webPort != 80 && webPort != 443 {
url += fmt.Sprintf(":%d", webPort)
}
// Add base path (remove trailing slash if present, we'll add it in node)
basePath := webBasePath
if basePath != "" && basePath != "/" {
if !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
url += basePath
} else {
url += "/"
}
return url
}
// ReloadNode reloads XRAY on a specific node. // ReloadNode reloads XRAY on a specific node.
func (s *NodeService) ReloadNode(node *model.Node) error { func (s *NodeService) ReloadNode(node *model.Node) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 30*time.Second)
Timeout: 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) url := fmt.Sprintf("%s/api/v1/reload", node.Address)
@ -476,8 +687,9 @@ func (s *NodeService) ReloadNode(node *model.Node) error {
// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung). // ForceReloadNode forcefully reloads XRAY on a specific node (even if hung).
func (s *NodeService) ForceReloadNode(node *model.Node) error { func (s *NodeService) ForceReloadNode(node *model.Node) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 30*time.Second)
Timeout: 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) url := fmt.Sprintf("%s/api/v1/force-reload", node.Address)
@ -539,15 +751,16 @@ func (s *NodeService) ReloadAllNodes() error {
// ValidateApiKey validates the API key by making a test request to the node. // ValidateApiKey validates the API key by making a test request to the node.
func (s *NodeService) ValidateApiKey(node *model.Node) error { func (s *NodeService) ValidateApiKey(node *model.Node) error {
client := &http.Client{ client, err := s.createHTTPClient(node, 5*time.Second)
Timeout: 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 // First, check if node is reachable via health endpoint
healthURL := fmt.Sprintf("%s/health", node.Address) healthURL := fmt.Sprintf("%s/health", node.Address)
healthResp, err := client.Get(healthURL) healthResp, err := client.Get(healthURL)
if err != nil { if err != nil {
logger.Errorf("Failed to connect to node %s at %s: %v", node.Address, healthURL, err) logger.Errorf("[Node: %s] Failed to connect at %s: %v", node.Name, healthURL, err)
return fmt.Errorf("failed to connect to node: %v", err) return fmt.Errorf("failed to connect to node: %v", err)
} }
healthResp.Body.Close() healthResp.Body.Close()
@ -566,11 +779,11 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
authHeader := fmt.Sprintf("Bearer %s", node.ApiKey) authHeader := fmt.Sprintf("Bearer %s", node.ApiKey)
req.Header.Set("Authorization", authHeader) req.Header.Set("Authorization", authHeader)
logger.Debugf("Validating API key for node %s at %s (key: %s)", node.Name, url, node.ApiKey) logger.Debugf("[Node: %s] Validating API key at %s", node.Name, url)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
logger.Errorf("Failed to connect to node %s: %v", node.Address, err) logger.Errorf("[Node: %s] Failed to connect: %v", node.Name, err)
return fmt.Errorf("failed to connect to node: %v", err) return fmt.Errorf("failed to connect to node: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -578,23 +791,24 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized { if resp.StatusCode == http.StatusUnauthorized {
logger.Warningf("Invalid API key for node %s (sent: %s): %s", node.Address, authHeader, string(body)) logger.Warningf("[Node: %s] Invalid API key: %s", node.Name, string(body))
return fmt.Errorf("invalid API key") return fmt.Errorf("invalid API key")
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
logger.Errorf("Node %s returned status %d: %s", node.Address, resp.StatusCode, string(body)) logger.Errorf("[Node: %s] Returned status %d: %s", node.Name, resp.StatusCode, string(body))
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body)) return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
} }
logger.Debugf("API key validated successfully for node %s", node.Name) logger.Debugf("[Node: %s] API key validated successfully", node.Name)
return nil return nil
} }
// GetNodeStatus retrieves the status of a node. // GetNodeStatus retrieves the status of a node.
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) { func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
client := &http.Client{ client, err := s.createHTTPClient(node, 5*time.Second)
Timeout: 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) url := fmt.Sprintf("%s/api/v1/status", node.Address)
@ -622,3 +836,44 @@ func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, e
return status, nil return status, nil
} }
// GetNodeLogs retrieves XRAY access logs from a node.
// Returns raw log lines as strings.
func (s *NodeService) GetNodeLogs(node *model.Node, count int, filter string) ([]string, error) {
client, err := s.createHTTPClient(node, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
url := fmt.Sprintf("%s/api/v1/logs?count=%d", node.Address, count)
if filter != "" {
url += "&filter=" + filter
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request node logs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
}
var response struct {
Logs []string `json:"logs"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return response.Logs, nil
}

View file

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

View file

@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/random" "github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/util/reflect_util" "github.com/mhsanaei/3x-ui/v2/util/reflect_util"
"github.com/mhsanaei/3x-ui/v2/web/cache"
"github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
@ -96,6 +97,8 @@ var defaultValueMap = map[string]string{
"ldapDefaultLimitIP": "0", "ldapDefaultLimitIP": "0",
// Multi-node mode // Multi-node mode
"multiNodeMode": "false", // "true" for multi-mode, "false" for single-mode "multiNodeMode": "false", // "true" for multi-mode, "false" for single-mode
// HWID tracking mode
"hwidMode": "client_header", // "off" = disabled, "client_header" = use x-hwid header (default), "legacy_fingerprint" = deprecated fingerprint-based (deprecated)
} }
// SettingService provides business logic for application settings management. // SettingService provides business logic for application settings management.
@ -112,15 +115,19 @@ func (s *SettingService) GetDefaultJsonConfig() (any, error) {
} }
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
var allSetting *entity.AllSetting
err := cache.GetOrSet(cache.KeySettingsAll, &allSetting, cache.TTLSettings, func() (interface{}, error) {
// Cache miss - fetch from database
db := database.GetDB() db := database.GetDB()
settings := make([]*model.Setting, 0) settings := make([]*model.Setting, 0)
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
allSetting := &entity.AllSetting{} result := &entity.AllSetting{}
t := reflect.TypeOf(allSetting).Elem() t := reflect.TypeOf(result).Elem()
v := reflect.ValueOf(allSetting).Elem() v := reflect.ValueOf(result).Elem()
fields := reflect_util.GetFields(t) fields := reflect_util.GetFields(t)
setSetting := func(key, value string) (err error) { setSetting := func(key, value string) (err error) {
@ -183,7 +190,10 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
} }
} }
return allSetting, nil return result, nil
})
return allSetting, err
} }
func (s *SettingService) ResetSettings() error { func (s *SettingService) ResetSettings() error {
@ -197,29 +207,54 @@ func (s *SettingService) ResetSettings() error {
} }
func (s *SettingService) getSetting(key string) (*model.Setting, error) { func (s *SettingService) getSetting(key string) (*model.Setting, error) {
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() db := database.GetDB()
setting := &model.Setting{} result := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error err := db.Model(model.Setting{}).Where("key = ?", key).First(result).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return setting, nil return result, nil
})
return setting, err
} }
func (s *SettingService) saveSetting(key string, value string) error { func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key) setting, err := s.getSetting(key)
db := database.GetDB() db := database.GetDB()
if database.IsNotFound(err) { if database.IsNotFound(err) {
return db.Create(&model.Setting{ err = db.Create(&model.Setting{
Key: key, Key: key,
Value: value, Value: value,
}).Error }).Error
} else if err != nil { } else if err != nil {
return err return err
} } else {
setting.Key = key setting.Key = key
setting.Value = value setting.Value = value
return db.Save(setting).Error err = 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) { func (s *SettingService) getString(key string) (string, error) {
@ -566,6 +601,11 @@ func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
} }
func (s *SettingService) GetIpLimitEnable() (bool, error) { func (s *SettingService) GetIpLimitEnable() (bool, error) {
// 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 // Check if multi-node mode is enabled
multiMode, err := s.GetMultiNodeMode() multiMode, err := s.GetMultiNodeMode()
if err == nil && multiMode { if err == nil && multiMode {
@ -578,6 +618,9 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) {
return false, err return false, err
} }
return (accessLogPath != "none" && accessLogPath != ""), nil return (accessLogPath != "none" && accessLogPath != ""), nil
})
return result, err
} }
// LDAP exported getters // LDAP exported getters
@ -671,6 +714,40 @@ func (s *SettingService) SetMultiNodeMode(enabled bool) error {
return s.setBool("multiNodeMode", enabled) return s.setBool("multiNodeMode", enabled)
} }
// GetHwidMode returns the HWID tracking mode.
// Returns: "off", "client_header", or "legacy_fingerprint"
func (s *SettingService) GetHwidMode() (string, error) {
mode, err := s.getString("hwidMode")
if err != nil {
return "client_header", err // Default to client_header on error
}
// Validate mode
validModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if !validModes[mode] {
// Invalid mode, return default
return "client_header", nil
}
return mode, nil
}
// SetHwidMode sets the HWID tracking mode.
// Valid values: "off", "client_header", "legacy_fingerprint"
func (s *SettingService) SetHwidMode(mode string) error {
validModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if !validModes[mode] {
return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", mode)
}
return s.setString("hwidMode", mode)
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil { if err := allSetting.CheckValid(); err != nil {
return err return err
@ -702,6 +779,12 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
} }
func (s *SettingService) GetDefaultSettings(host string) (any, error) { func (s *SettingService) GetDefaultSettings(host string) (any, error) {
// 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) type settingFunc func() (any, error)
settings := map[string]settingFunc{ settings := map[string]settingFunc{
"expireDiff": func() (any, error) { return s.GetExpireDiff() }, "expireDiff": func() (any, error) { return s.GetExpireDiff() },
@ -720,14 +803,20 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
} }
result := make(map[string]any) res := make(map[string]any)
for key, fn := range settings { for key, fn := range settings {
value, err := fn() value, err := fn()
if err != nil { if err != nil {
return "", err return nil, err
} }
result[key] = value res[key] = value
}
return res, nil
})
if err != nil {
return nil, err
} }
subEnable := result["subEnable"].(bool) subEnable := result["subEnable"].(bool)

View file

@ -401,16 +401,16 @@ func (s *XrayService) restartXrayMultiMode(isForce bool) error {
// Marshal config to JSON // Marshal config to JSON
configJSON, err := json.MarshalIndent(&nodeConfig, "", " ") configJSON, err := json.MarshalIndent(&nodeConfig, "", " ")
if err != nil { if err != nil {
logger.Errorf("Failed to marshal config for node %d: %v", node.Id, err) logger.Errorf("[Node: %s] Failed to marshal config: %v", node.Name, err)
continue continue
} }
// Send to node // Send to node
if err := s.nodeService.ApplyConfigToNode(node, configJSON); err != nil { if err := s.nodeService.ApplyConfigToNode(node, configJSON); err != nil {
logger.Errorf("Failed to apply config to node %d (%s): %v", node.Id, node.Name, err) logger.Errorf("[Node: %s] Failed to apply config: %v", node.Name, err)
// Continue with other nodes even if one fails // Continue with other nodes even if one fails
} else { } else {
logger.Infof("Successfully applied config to node %d (%s)", node.Id, node.Name) logger.Infof("[Node: %s] Successfully applied config", node.Name)
} }
} }

View file

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

View file

@ -23,6 +23,11 @@
"indefinite" = "Бесконечно" "indefinite" = "Бесконечно"
"unlimited" = "Безлимит" "unlimited" = "Безлимит"
"none" = "Пусто" "none" = "Пусто"
"hwidSettings" = "Настройки HWID"
"hwidEnabled" = "Включить ограничение по HWID"
"maxHwid" = "Максимум устройств (HWID)"
"hwidBetaWarningTitle" = "Бета-функция"
"hwidBetaWarningDesc" = "Отслеживание HWID находится в бета-версии и работает только с клиентами happ и v2raytun. Другие клиенты могут не поддерживать регистрацию HWID."
"qrCode" = "QR-код" "qrCode" = "QR-код"
"info" = "Информация" "info" = "Информация"
"edit" = "Изменить" "edit" = "Изменить"
@ -72,6 +77,8 @@
"emptyBalancersDesc" = "Нет добавленных балансировщиков." "emptyBalancersDesc" = "Нет добавленных балансировщиков."
"emptyReverseDesc" = "Нет добавленных реверс-прокси." "emptyReverseDesc" = "Нет добавленных реверс-прокси."
"somethingWentWrong" = "Что-то пошло не так" "somethingWentWrong" = "Что-то пошло не так"
"active" = "Активен"
"inactive" = "Неактивен"
[subscription] [subscription]
"title" = "Информация о подписке" "title" = "Информация о подписке"
@ -87,18 +94,6 @@
"unlimited" = "Неограниченно" "unlimited" = "Неограниченно"
"noExpiry" = "Бессрочно" "noExpiry" = "Бессрочно"
[menu]
"theme" = "Тема"
"dark" = "Темная"
"ultraDark" = "Очень темная"
"dashboard" = "Дашборд"
"inbounds" = "Подключения"
"settings" = "Настройки"
"xray" = "Настройки Xray"
"nodes" = "Ноды"
"logout" = "Выход"
"link" = "Управление"
[pages.login] [pages.login]
"hello" = "Привет!" "hello" = "Привет!"
"title" = "Добро пожаловать!" "title" = "Добро пожаловать!"
@ -597,6 +592,7 @@
"twoFactorModalError" = "Неверный код" "twoFactorModalError" = "Неверный код"
[pages.nodes] [pages.nodes]
responseTime = "Время ответа"
"title" = "Управление нодами" "title" = "Управление нодами"
"addNewNode" = "Добавить новую ноду" "addNewNode" = "Добавить новую ноду"
"addNode" = "Добавить ноду" "addNode" = "Добавить ноду"
@ -616,6 +612,17 @@
"address" = "Адрес" "address" = "Адрес"
"status" = "Статус" "status" = "Статус"
"assignedInbounds" = "Назначенные подключения" "assignedInbounds" = "Назначенные подключения"
"connecting" = "Устанавливаю соединение"
"generatingApiKey" = "Генерирую API ключ"
"registeringNode" = "Регистрирую ноду"
"done" = "Готово"
"connectionEstablished" = "Соединение установлено"
"connectionError" = "Ошибка соединения"
"apiKeyGenerated" = "API ключ сгенерирован"
"generationError" = "Ошибка генерации"
"nodeRegistered" = "Нода зарегистрирована"
"registrationError" = "Ошибка регистрации"
"nodeAddedSuccessfully" = "Нода успешно добавлена!"
"checkAll" = "Проверить все" "checkAll" = "Проверить все"
"check" = "Проверить" "check" = "Проверить"
"online" = "Онлайн" "online" = "Онлайн"
@ -627,7 +634,7 @@
"validUrl" = "Должен быть действительным URL (http:// или https://)" "validUrl" = "Должен быть действительным URL (http:// или https://)"
"validPort" = "Порт должен быть числом от 1 до 65535" "validPort" = "Порт должен быть числом от 1 до 65535"
"duplicateNode" = "Нода с таким адресом и портом уже существует" "duplicateNode" = "Нода с таким адресом и портом уже существует"
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)" "fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100 или домен)"
"enterApiKey" = "Пожалуйста, введите API ключ" "enterApiKey" = "Пожалуйста, введите API ключ"
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)" "apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
"leaveEmptyToKeep" = "оставьте пустым чтобы не менять" "leaveEmptyToKeep" = "оставьте пустым чтобы не менять"
@ -649,6 +656,15 @@
"reloadError" = "Не удалось перезагрузить ноду" "reloadError" = "Не удалось перезагрузить ноду"
"reloadAllSuccess" = "Все ноды успешно перезагружены" "reloadAllSuccess" = "Все ноды успешно перезагружены"
"reloadAllError" = "Не удалось перезагрузить некоторые ноды" "reloadAllError" = "Не удалось перезагрузить некоторые ноды"
"tlsSettings" = "Настройки TLS/HTTPS"
"useTls" = "Использовать TLS/HTTPS"
"useTlsHint" = "Включить TLS/HTTPS для API вызовов к этой ноде"
"certPath" = "Путь к сертификату"
"certPathHint" = "Путь к файлу сертификата CA (опционально, для кастомного CA)"
"keyPath" = "Путь к приватному ключу"
"keyPathHint" = "Путь к файлу приватного ключа (опционально, для клиентского сертификата)"
"insecureTls" = "Пропустить проверку сертификата"
"insecureTlsHint" = "⚠️ Не рекомендуется: пропустить проверку TLS сертификата (небезопасно)"
[pages.nodes.toasts] [pages.nodes.toasts]
"createSuccess" = "Нода успешно создана" "createSuccess" = "Нода успешно создана"
@ -662,6 +678,124 @@
"mappingError" = "Не удалось получить привязку ноды" "mappingError" = "Не удалось получить привязку ноды"
"invalidInboundId" = "Неверный ID подключения" "invalidInboundId" = "Неверный ID подключения"
[pages.clients]
"title" = "Управление клиентами"
"addClient" = "Добавить клиента"
"operate" = "Действия"
"email" = "Email"
"inbounds" = "Назначенные подключения"
"traffic" = "Трафик"
"expiryTime" = "Срок действия"
"enable" = "Включено"
"loadError" = "Не удалось загрузить список клиентов"
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить этого клиента?"
"deleteSuccess" = "Клиент успешно удален"
"deleteError" = "Не удалось удалить клиента"
"updateSuccess" = "Клиент успешно обновлен"
"updateError" = "Не удалось обновить клиента"
"addClientNotImplemented" = "Функция добавления клиента будет реализована в ближайшее время"
"editClientNotImplemented" = "Функция редактирования клиента будет реализована в ближайшее время"
"editClient" = "Редактировать клиента"
"addSuccess" = "Клиент успешно добавлен"
"addError" = "Не удалось добавить клиента"
"emailRequired" = "Email обязателен для заполнения"
"maxHwidDesc" = "Установите 0 для неограниченного количества устройств. Если новое устройство подключается и лимит достигнут, подключение будет заблокировано."
"registeredHwids" = "Зарегистрированные устройства"
"registeredHwidsDesc" = "Список аппаратных ID (устройств), которые подключались к этому клиенту. Только активные устройства учитываются в лимите."
"noHwidsRegistered" = "Устройства еще не зарегистрированы."
"confirmDeleteHwid" = "Вы уверены, что хотите удалить это устройство? Это позволит новому устройству подключиться, если лимит не достигнут."
"hwidDeleteSuccess" = "Устройство успешно удалено."
"hwidDeleteError" = "Не удалось удалить устройство."
"deviceInfo" = "Информация об устройстве"
"firstSeen" = "Первое подключение"
"lastSeen" = "Последнее подключение"
"actions" = "Действия"
[pages.clients.toasts]
"clientCreateSuccess" = "Клиент успешно создан"
"clientUpdateSuccess" = "Клиент успешно обновлен"
"clientDeleteSuccess" = "Клиент успешно удален"
[pages.hosts]
"title" = "Управление хостами"
"addNewHost" = "Добавить новый хост"
"addHost" = "Добавить хост"
"hostName" = "Имя хоста"
"hostAddress" = "Адрес хоста"
"hostPort" = "Порт"
"hostProtocol" = "Протокол"
"operate" = "Действия"
"name" = "Имя"
"address" = "Адрес"
"port" = "Порт"
"protocol" = "Протокол"
"assignedInbounds" = "Назначенные подключения"
"enable" = "Включено"
"multiNodeModeRequired" = "Для управления хостами должен быть включен режим Multi-Node"
"enterHostNameAndAddress" = "Пожалуйста, введите имя и адрес хоста"
"enterHostName" = "Пожалуйста, введите имя хоста"
"enterHostAddress" = "Пожалуйста, введите адрес хоста"
"editHost" = "Редактировать хост"
"modalNotAvailable" = "Модальное окно хоста недоступно"
"loadError" = "Не удалось загрузить список хостов"
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить этот хост?"
"deleteSuccess" = "Хост успешно удален"
"deleteError" = "Не удалось удалить хост"
"updateSuccess" = "Хост успешно обновлен"
"updateError" = "Не удалось обновить хост"
"addSuccess" = "Хост успешно добавлен"
"addError" = "Не удалось добавить хост"
"editHostNotImplemented" = "Функция редактирования хоста будет реализована в ближайшее время"
[menu]
"theme" = "Тема"
"dark" = "Темная"
"ultraDark" = "Очень темная"
"glassMorphism" = "Glass Morphism"
"dashboard" = "Обзор"
"inbounds" = "Подключения"
"clients" = "Клиенты"
"settings" = "Настройки панели"
"xray" = "Конфигурация Xray"
"nodes" = "Ноды"
"hosts" = "Хосты"
"logout" = "Выйти"
"link" = "Управление"
"tutorial" = "Обучалка"
"restartTutorial" = "Повторить обучение"
[tutorial]
"title" = "Инструкция по меню веб-панели"
"next" = "Далее"
"prev" = "Назад"
"skip" = "Пропустить"
"finish" = "Завершить"
"step" = "Шаг"
"of" = "из"
"dashboardTitle" = "1. Панель"
"dashboardDesc" = "Главный интерфейс для управления всей системой. Через панель мы:\n\n• Настраиваем и контролируем ноды (серверы с xray)\n• Управляем клиентами\n• Настраиваем инбаунды"
"dashboardHint" = "Панель — это центр управления. Здесь создаются ноды, инбаунды и назначаются клиенты."
"nodeTitle" = "2. Нода"
"nodeDesc" = "Отдельное ядро Xray с API для связи с панелью. Нода выполняет работу по обработке подключений и служит «мозгом» для инбаундов."
"nodeHint" = "Нода — это серверное ядро. Панель взаимодействует с ним через API, чтобы управлять конфигами и подключениями."
"inboundTitle" = "3. Инбаунд"
"inboundDesc" = "Конфигурация или профиль для ноды.\n\n• Создаётся подключение к ноде\n• Подписывается на одну или несколько нод"
"inboundHint" = "Инбаунд — это профиль подключения. Через него клиенты получают доступ к нужным нодам."
"clientTitle" = "4. Клиент"
"clientDesc" = "Пользователь системы, которому можно назначать один или несколько инбаундов.\n\nНапример:\n• Инбаунд 1 → нода из белого списка\n• Инбаунд 2 → обычный забугорный сервер\n\nКлиент может использовать любой или все инбаунды, которые ему назначены"
"clientHint" = "Клиент — это пользователь. Назначайте ему инбаунды, чтобы он мог подключаться к нужным нодам."
"hostTitle" = "5. Хосты"
"hostDesc" = "Внешние адреса для подключения.\n\n• Прокси-балансир, скрывающий прямые адреса нод\n• Можно распределять нагрузку между несколькими нодами\n• Подменяем адрес ноды на нужный хост в инбаунде\n\nПример:\nИнбаунд подключается к хосту-балансиру, а тот уже распределяет подключение на реальные ноды"
"hostHint" = "Хосты — это виртуальные адреса. Используйте их для балансировки нагрузки и скрытия реальных серверов."
"settingsTitle" = "6. Настройки панели"
"settingsDesc" = "Раздел для общей конфигурации панели.\n\n• Включение/отключение различных функций\n• Настройка внешнего вида и поведения панели"
"settingsHint" = "Настройки панели — здесь вы можете управлять функциями и конфигурацией самой панели."
"xrayTitle" = "7. Конфигурация Xray"
"xrayDesc" = "Раздел для тонкой настройки ядра Xray.\n\n• Роутинг\n• Параметры соединений и маршрутизации трафика\n• Дополнительные продвинутые конфиги ноды"
"xrayHint" = "Конфигурация Xray — для продвинутой конфигурации ядра, управления роутингом и другими параметрами ноды."
[pages.settings.toasts] [pages.settings.toasts]
"modifySettings" = "Настройки изменены" "modifySettings" = "Настройки изменены"
"getSettings" = "Произошла ошибка при получении параметров." "getSettings" = "Произошла ошибка при получении параметров."

View file

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

View file

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

View file

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

View file

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