diff --git a/database/db.go b/database/db.go index 6b579dd9..f1bc99df 100644 --- a/database/db.go +++ b/database/db.go @@ -38,6 +38,13 @@ func initModels() error { &model.InboundClientIps{}, &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, + &model.Node{}, + &model.InboundNodeMapping{}, + &model.ClientEntity{}, + &model.ClientInboundMapping{}, + &model.Host{}, + &model.HostInboundMapping{}, + &model.ClientHWID{}, // HWID tracking for clients } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 6225df52..a649d5e8 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -53,6 +53,8 @@ type Inbound struct { StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` Sniffing string `json:"sniffing" form:"sniffing"` + NodeId *int `json:"nodeId,omitempty" form:"-" gorm:"-"` // Node ID (not stored in Inbound table, from mapping) - DEPRECATED: kept only for backward compatibility with old clients, use NodeIds instead + NodeIds []int `json:"nodeIds,omitempty" form:"-" gorm:"-"` // Node IDs array (not stored in Inbound table, from mapping) - use this for multi-node support } // OutboundTraffics tracks traffic statistics for Xray outbound connections. @@ -105,6 +107,8 @@ type Setting struct { } // Client represents a client configuration for Xray inbounds with traffic limits and settings. +// This is a legacy struct used for JSON parsing from inbound Settings. +// For database operations, use ClientEntity instead. type Client struct { ID string `json:"id"` // Unique client identifier Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") @@ -122,3 +126,129 @@ type Client struct { CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } + +// ClientEntity represents a client as a separate database entity. +// Clients can be assigned to multiple inbounds. +type ClientEntity struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_user_email"` // Client email identifier (unique per user) + UUID string `json:"uuid" form:"uuid"` // UUID/ID for VMESS/VLESS + Security string `json:"security" form:"security"` // Security method (e.g., "auto", "aes-128-gcm") + Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks) + Flow string `json:"flow" form:"flow"` // Flow control (XTLS) + LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client + TotalGB float64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB (supports decimal values like 0.01 for MB) + ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp + Enable bool `json:"enable" form:"enable"` // Whether the client is enabled + Status string `json:"status" form:"status" gorm:"default:active"` // Client status: active, expired_traffic, expired_time + TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications + SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier + Comment string `json:"comment" form:"comment"` // Client comment + Reset int `json:"reset" form:"reset"` // Reset period in days + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to + + // Traffic statistics (stored directly in ClientEntity table) + Up int64 `json:"up,omitempty" form:"-" gorm:"default:0"` // Upload traffic in bytes + Down int64 `json:"down,omitempty" form:"-" gorm:"default:0"` // Download traffic in bytes + AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"default:0"` // All-time traffic usage + LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"default:0"` // Last online timestamp + + // HWID (Hardware ID) restrictions + HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client + MaxHWID int `json:"maxHwid" form:"maxHwid" gorm:"column:max_hwid;default:1"` // Maximum number of allowed HWID devices (0 = unlimited) + HWIDs []*ClientHWID `json:"hwids,omitempty" form:"-" gorm:"-"` // Registered HWIDs for this client (loaded from client_hwids table, not stored in ClientEntity table) +} + +// Node represents a worker node in multi-node architecture. +type Node struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + Name string `json:"name" form:"name"` // Node name/identifier + Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...") + ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication + Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown + LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp + ResponseTime int64 `json:"responseTime" gorm:"default:0"` // Response time in milliseconds (0 = not measured or error) + UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls + CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA) + KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA) + InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended) + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp +} + +// InboundNodeMapping maps inbounds to nodes in multi-node mode. +type InboundNodeMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID + NodeId int `json:"nodeId" form:"nodeId" gorm:"uniqueIndex:idx_inbound_node"` // Node ID +} + +// ClientInboundMapping maps clients to inbounds (many-to-many relationship). +type ClientInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"uniqueIndex:idx_client_inbound"` // Client ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_client_inbound"` // Inbound ID +} + +// Host represents a proxy/balancer host configuration for multi-node mode. +// Hosts can override the node address when generating subscription links. +type Host struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Name string `json:"name" form:"name"` // Host name/identifier + Address string `json:"address" form:"address"` // Host address (IP or domain) + Port int `json:"port" form:"port"` // Host port (0 means use inbound port) + Protocol string `json:"protocol" form:"protocol"` // Protocol override (optional) + Remark string `json:"remark" form:"remark"` // Host remark/description + Enable bool `json:"enable" form:"enable"` // Whether the host is enabled + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this host applies to +} + +// HostInboundMapping maps hosts to inbounds (many-to-many relationship). +type HostInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + HostId int `json:"hostId" form:"hostId" gorm:"uniqueIndex:idx_host_inbound"` // Host ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_host_inbound"` // Inbound ID +} + +// ClientHWID represents a hardware ID (HWID) associated with a client. +// HWID is provided explicitly by client applications via HTTP headers (x-hwid). +// Server MUST NOT generate or derive HWID from IP, User-Agent, or access logs. +type ClientHWID struct { + // TableName specifies the table name for GORM + // GORM by default would use "client_hwids" but the actual table is "client_hw_ids" + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"column:client_id;index:idx_client_hwid"` // Client ID + HWID string `json:"hwid" form:"hwid" gorm:"column:hwid;index:idx_client_hwid"` // Hardware ID (unique per client, provided by client via x-hwid header) + DeviceName string `json:"deviceName" form:"deviceName" gorm:"column:device_name"` // Optional device name/description (deprecated, use DeviceModel instead) + DeviceOS string `json:"deviceOs" form:"deviceOs" gorm:"column:device_os"` // Device operating system (from x-device-os header) + DeviceModel string `json:"deviceModel" form:"deviceModel" gorm:"column:device_model"` // Device model (from x-device-model header) + OSVersion string `json:"osVersion" form:"osVersion" gorm:"column:os_version"` // OS version (from x-ver-os header) + FirstSeenAt int64 `json:"firstSeenAt" gorm:"column:first_seen_at;autoCreateTime"` // First time this HWID was seen (timestamp) + LastSeenAt int64 `json:"lastSeenAt" gorm:"column:last_seen_at;autoUpdateTime"` // Last time this HWID was used (timestamp) + FirstSeenIP string `json:"firstSeenIp" form:"firstSeenIp" gorm:"column:first_seen_ip"` // IP address when first seen + IsActive bool `json:"isActive" form:"isActive" gorm:"column:is_active;default:true"` // Whether this HWID is currently active + IPAddress string `json:"ipAddress" form:"ipAddress" gorm:"column:ip_address"` // Last known IP address for this HWID + UserAgent string `json:"userAgent" form:"userAgent" gorm:"column:user_agent"` // User agent or client identifier (if available) + BlockedAt *int64 `json:"blockedAt,omitempty" form:"blockedAt" gorm:"column:blocked_at"` // Timestamp when HWID was blocked (null if not blocked) + BlockReason string `json:"blockReason,omitempty" form:"blockReason" gorm:"column:block_reason"` // Reason for blocking (e.g., "HWID limit exceeded") + + // Legacy fields (deprecated, kept for backward compatibility) + FirstSeen int64 `json:"firstSeen,omitempty" gorm:"-"` // Deprecated: use FirstSeenAt + LastSeen int64 `json:"lastSeen,omitempty" gorm:"-"` // Deprecated: use LastSeenAt +} + +// TableName specifies the table name for ClientHWID. +// GORM by default would use "client_hwids" but the actual table is "client_hw_ids" +func (ClientHWID) TableName() string { + return "client_hw_ids" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 198df198..53784309 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,4 +13,4 @@ services: XUI_ENABLE_FAIL2BAN: "true" tty: true network_mode: host - restart: unless-stopped + restart: unless-stopped \ No newline at end of file diff --git a/go.mod b/go.mod index 126a109b..73e56710 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,9 @@ module github.com/mhsanaei/3x-ui/v2 +// Local development - use local files instead of GitHub +// These replace directives ensure we use local code during development +// Remove these when changes are pushed to GitHub + go 1.25.5 require ( @@ -32,13 +36,16 @@ require ( require ( github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/alicebob/miniredis/v2 v2.35.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.2 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -70,6 +77,7 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect + github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/refraction-networking/utls v1.8.1 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -86,6 +94,7 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/arch v0.23.0 // indirect @@ -101,3 +110,8 @@ require ( gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +// Local development - use local files instead of GitHub +// This ensures we use local code during development +// Remove this when changes are pushed to GitHub +replace github.com/mhsanaei/3x-ui/v2 => ./ diff --git a/go.sum b/go.sum index 70c47d08..ba917e82 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -12,6 +14,8 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -22,6 +26,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -149,6 +155,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= @@ -207,6 +215,8 @@ github.com/xtls/xray-core v1.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0e github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/logger/logger.go b/logger/logger.go index 7d26dcd0..72a8d3ac 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" "github.com/mhsanaei/3x-ui/v2/config" @@ -69,12 +70,19 @@ func initDefaultBackend() logging.Backend { includeTime = true } else { // Unix-like: Try syslog, fallback to stderr - if syslogBackend, err := logging.NewSyslogBackend(""); err != nil { - fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err) - backend = logging.NewLogBackend(os.Stderr, "", 0) - includeTime = os.Getppid() > 0 - } else { + // Try syslog with "x-ui" tag first + if syslogBackend, err := logging.NewSyslogBackend("x-ui"); err == nil { backend = syslogBackend + } else { + // Try with empty tag as fallback + if syslogBackend2, err2 := logging.NewSyslogBackend(""); err2 == nil { + backend = syslogBackend2 + } else { + // Syslog unavailable - use stderr (normal in containers/Docker) + // In containers, syslog is often not configured - this is normal and expected + backend = logging.NewLogBackend(os.Stderr, "", 0) + includeTime = os.Getppid() > 0 + } } } @@ -202,6 +210,27 @@ func addToBuffer(level string, newLog string) { level: logLevel, log: newLog, }) + + // If running on node, push log to panel in real-time + // Check if we're in node mode by checking for NODE_API_KEY environment variable + if os.Getenv("NODE_API_KEY") != "" { + // Format log line as "timestamp level - message" for panel + logLine := fmt.Sprintf("%s %s - %s", t.Format(timeFormat), strings.ToUpper(level), newLog) + // Use build tag or lazy initialization to avoid circular dependency + // For now, we'll use a simple check - if node/logs package is available + pushLogToPanel(logLine) + } +} + +// pushLogToPanel pushes a log line to the panel (called from node mode only). +// This function will be implemented in node package to avoid circular dependency. +var pushLogToPanel = func(logLine string) { + // Default: no-op, will be overridden by node package if available +} + +// SetLogPusher sets the function to push logs to panel (called from node package). +func SetLogPusher(pusher func(string)) { + pushLogToPanel = pusher } // GetLogs retrieves up to c log entries from the buffer that are at or below the specified level. diff --git a/main.go b/main.go index 8096616c..5096daa6 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,13 @@ func runWebServer() { log.Fatalf("Error initializing database: %v", err) } + // Initialize Redis cache (embedded mode by default) + err = web.InitRedisCache("") + if err != nil { + log.Fatalf("Error initializing Redis cache: %v", err) + } + defer web.CloseRedisCache() + var server *web.Server server = web.NewServer() global.SetWebServer(server) diff --git a/node/Dockerfile b/node/Dockerfile new file mode 100644 index 00000000..31a73050 --- /dev/null +++ b/node/Dockerfile @@ -0,0 +1,124 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +# Install build dependencies +RUN apk --no-cache add curl unzip + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build node service +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o node-service ./node/main.go + +# Download XRAY Core based on target architecture +# TARGETARCH is automatically set by Docker BuildKit +ARG TARGETARCH=amd64 +ARG TARGETOS=linux +RUN mkdir -p bin && \ + cd bin && \ + case ${TARGETARCH} in \ + amd64) \ + ARCH="64" \ + FNAME="amd64" \ + ;; \ + arm64) \ + ARCH="arm64-v8a" \ + FNAME="arm64" \ + ;; \ + arm) \ + ARCH="arm32-v7a" \ + FNAME="arm32" \ + ;; \ + armv6) \ + ARCH="arm32-v6" \ + FNAME="armv6" \ + ;; \ + 386) \ + ARCH="32" \ + FNAME="i386" \ + ;; \ + *) \ + ARCH="64" \ + FNAME="amd64" \ + ;; \ + esac && \ + echo "Downloading Xray for ${TARGETARCH} (ARCH=${ARCH}, FNAME=${FNAME})" && \ + curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip" && \ + echo "Unzipping..." && \ + unzip -q "Xray-linux-${ARCH}.zip" && \ + echo "Files after unzip:" && \ + ls -la && \ + echo "Removing zip and old data files..." && \ + rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat && \ + echo "Renaming xray to xray-linux-${FNAME}..." && \ + mv xray "xray-linux-${FNAME}" && \ + chmod +x "xray-linux-${FNAME}" && \ + echo "Verifying xray binary:" && \ + ls -lh "xray-linux-${FNAME}" && \ + test -f "xray-linux-${FNAME}" && echo "✓ xray-linux-${FNAME} exists" && \ + echo "Downloading geo files..." && \ + curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat && \ + curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat && \ + curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat && \ + curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat && \ + curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat && \ + curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat && \ + echo "Final files in bin:" && \ + ls -lah && \ + echo "File sizes:" && \ + du -h * && \ + cd .. && \ + echo "Verifying files in /build/bin:" && \ + ls -lah /build/bin/ + +# Runtime stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /build/node-service . + +# Copy XRAY binary and data files +# Use wildcard to copy all files from bin directory +COPY --from=builder /build/bin/ ./bin/ + +# Verify files were copied and make executable +RUN echo "Contents of /app/bin after COPY:" && \ + ls -la ./bin/ && \ + echo "Looking for xray binary..." && \ + if [ -f ./bin/xray-linux-amd64 ]; then \ + chmod +x ./bin/xray-linux-amd64 && \ + echo "✓ Found and made executable: xray-linux-amd64"; \ + elif [ -f ./bin/xray ]; then \ + chmod +x ./bin/xray && \ + mv ./bin/xray ./bin/xray-linux-amd64 && \ + echo "✓ Found xray, renamed to xray-linux-amd64"; \ + else \ + echo "✗ ERROR: No xray binary found!" && \ + echo "All files in bin directory:" && \ + find ./bin -type f -o -type l && \ + exit 1; \ + fi + +# Create directories for config and logs +RUN mkdir -p /app/config /app/logs + +# Set environment variables for paths +ENV XUI_BIN_FOLDER=/app/bin +ENV XUI_LOG_FOLDER=/app/logs + +# Expose API port +EXPOSE 8080 + +# Run node service +# The API key will be read from NODE_API_KEY environment variable +CMD ["./node-service", "-port", "8080"] diff --git a/node/README.md b/node/README.md new file mode 100644 index 00000000..a672ee7b --- /dev/null +++ b/node/README.md @@ -0,0 +1,79 @@ +# 3x-ui Node Service + +Node service (worker) for 3x-ui multi-node architecture. + +## Description + +This service runs on separate servers and manages XRAY Core instances. The 3x-ui panel (master) sends configurations to nodes via REST API. + +## Features + +- REST API for XRAY Core management +- Apply configurations from the panel +- Reload XRAY without stopping the container +- Status and health checks + +## API Endpoints + +### `GET /health` +Health check endpoint (no authentication required) + +### `POST /api/v1/apply` +Apply new XRAY configuration +- **Headers**: `Authorization: Bearer ` +- **Body**: XRAY JSON configuration + +### `POST /api/v1/reload` +Reload XRAY +- **Headers**: `Authorization: Bearer ` + +### `POST /api/v1/force-reload` +Force reload XRAY (stops and restarts) +- **Headers**: `Authorization: Bearer ` + +### `GET /api/v1/status` +Get XRAY status +- **Headers**: `Authorization: Bearer ` + +### `GET /api/v1/stats` +Get traffic statistics and online clients +- **Headers**: `Authorization: Bearer ` +- **Query Parameters**: `reset=true` to reset statistics after reading + +## Running + +### Docker Compose + +```bash +cd node +NODE_API_KEY=your-secure-api-key docker-compose up -d --build +``` + +**Note:** XRAY Core is automatically downloaded during Docker image build for your architecture. Docker BuildKit automatically detects the host architecture. To explicitly specify the architecture, use: + +```bash +DOCKER_BUILDKIT=1 docker build --build-arg TARGETARCH=arm64 -t 3x-ui-node -f node/Dockerfile .. +``` + +### Manual + +```bash +go run node/main.go -port 8080 -api-key your-secure-api-key +``` + +## Environment Variables + +- `NODE_API_KEY` - API key for authentication (required) + +## Structure + +``` +node/ +├── main.go # Entry point +├── api/ +│ └── server.go # REST API server +├── xray/ +│ └── manager.go # XRAY process management +├── Dockerfile # Docker image +└── docker-compose.yml +``` diff --git a/node/api/server.go b/node/api/server.go new file mode 100644 index 00000000..3cd189ea --- /dev/null +++ b/node/api/server.go @@ -0,0 +1,303 @@ +// Package api provides REST API endpoints for the node service. +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/mhsanaei/3x-ui/v2/logger" + nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config" + nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs" + "github.com/mhsanaei/3x-ui/v2/node/xray" + "github.com/gin-gonic/gin" +) + +// Server provides REST API for managing the node. +type Server struct { + port int + apiKey string + xrayManager *xray.Manager + httpServer *http.Server +} + +// NewServer creates a new API server instance. +func NewServer(port int, apiKey string, xrayManager *xray.Manager) *Server { + return &Server{ + port: port, + apiKey: apiKey, + xrayManager: xrayManager, + } +} + +// Start starts the HTTP server. +func (s *Server) Start() error { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(s.authMiddleware()) + + // Health check endpoint (no auth required) + router.GET("/health", s.health) + + // Registration endpoint (no auth required, used for initial setup) + router.POST("/api/v1/register", s.register) + + // API endpoints (require auth) + api := router.Group("/api/v1") + { + api.POST("/apply-config", s.applyConfig) + api.POST("/reload", s.reload) + api.POST("/force-reload", s.forceReload) + api.GET("/status", s.status) + api.GET("/stats", s.stats) + api.GET("/logs", s.getLogs) + api.GET("/service-logs", s.getServiceLogs) + } + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + logger.Infof("API server listening on port %d", s.port) + return s.httpServer.ListenAndServe() +} + +// Stop stops the HTTP server. +func (s *Server) Stop() error { + if s.httpServer == nil { + return nil + } + return s.httpServer.Close() +} + +// authMiddleware validates API key from Authorization header. +func (s *Server) authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip auth for health and registration endpoints + if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" { + c.Next() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"}) + c.Abort() + return + } + + // Support both "Bearer " and direct key + apiKey := authHeader + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + apiKey = authHeader[7:] + } + + if apiKey != s.apiKey { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + c.Abort() + return + } + + c.Next() + } +} + +// health returns the health status of the node. +func (s *Server) health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "service": "3x-ui-node", + }) +} + +// applyConfig applies a new XRAY configuration. +func (s *Server) applyConfig(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + // Try to parse as JSON with optional panelUrl field + var requestData struct { + Config json.RawMessage `json:"config"` + PanelURL string `json:"panelUrl,omitempty"` + } + + // First try to parse as new format with panelUrl + if err := json.Unmarshal(body, &requestData); err == nil && requestData.PanelURL != "" { + // New format: { "config": {...}, "panelUrl": "http://..." } + body = requestData.Config + // Set panel URL for log pusher + nodeLogs.SetPanelURL(requestData.PanelURL) + } else { + // Old format: just JSON config, validate it + var configJSON json.RawMessage + if err := json.Unmarshal(body, &configJSON); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) + return + } + } + + if err := s.xrayManager.ApplyConfig(body); err != nil { + logger.Errorf("Failed to apply config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Configuration applied successfully"}) +} + +// reload reloads XRAY configuration. +func (s *Server) reload(c *gin.Context) { + if err := s.xrayManager.Reload(); err != nil { + logger.Errorf("Failed to reload: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "XRAY reloaded successfully"}) +} + +// forceReload forcefully reloads XRAY even if it's hung or not running. +func (s *Server) forceReload(c *gin.Context) { + if err := s.xrayManager.ForceReload(); err != nil { + logger.Errorf("Failed to force reload: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "XRAY force reloaded successfully"}) +} + +// status returns the current status of XRAY. +func (s *Server) status(c *gin.Context) { + status := s.xrayManager.GetStatus() + c.JSON(http.StatusOK, status) +} + +// stats returns traffic and online clients statistics from XRAY. +func (s *Server) stats(c *gin.Context) { + // Get reset parameter (default: false) + reset := c.DefaultQuery("reset", "false") == "true" + + stats, err := s.xrayManager.GetStats(reset) + if err != nil { + logger.Errorf("Failed to get stats: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// getLogs returns XRAY access logs from the node. +func (s *Server) getLogs(c *gin.Context) { + // Get query parameters + countStr := c.DefaultQuery("count", "100") + filter := c.DefaultQuery("filter", "") + + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > 10000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"}) + return + } + + logs, err := s.xrayManager.GetLogs(count, filter) + if err != nil { + logger.Errorf("Failed to get logs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"logs": logs}) +} + +// getServiceLogs returns service application logs from the node (node service logs and XRAY core logs). +func (s *Server) getServiceLogs(c *gin.Context) { + // Get query parameters + countStr := c.DefaultQuery("count", "100") + level := c.DefaultQuery("level", "debug") + + count, err := strconv.Atoi(countStr) + if err != nil || count < 1 || count > 10000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid count parameter (must be 1-10000)"}) + return + } + + // Get logs from logger buffer + logs := logger.GetLogs(count, level) + c.JSON(http.StatusOK, gin.H{"logs": logs}) +} + +// register handles node registration from the panel. +// This endpoint receives an API key from the panel and saves it persistently. +// No authentication required - this is the initial setup step. +func (s *Server) register(c *gin.Context) { + type RegisterRequest struct { + ApiKey string `json:"apiKey" binding:"required"` // API key generated by panel + PanelURL string `json:"panelUrl,omitempty"` // Panel URL (optional) + NodeAddress string `json:"nodeAddress,omitempty"` // Node address (optional) + } + + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Check if node is already registered + existingConfig := nodeConfig.GetConfig() + if existingConfig.ApiKey != "" { + logger.Warningf("Node is already registered. Rejecting registration attempt to prevent overwriting existing API key") + c.JSON(http.StatusConflict, gin.H{ + "error": "Node is already registered. API key cannot be overwritten", + "message": "This node has already been registered. If you need to re-register, please remove the node-config.json file first", + }) + return + } + + // Save API key to config file (only if not already registered) + if err := nodeConfig.SetApiKey(req.ApiKey, false); err != nil { + logger.Errorf("Failed to save API key: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save API key: " + err.Error()}) + return + } + + // Update API key in server (for immediate use) + s.apiKey = req.ApiKey + + // Save panel URL if provided + if req.PanelURL != "" { + if err := nodeConfig.SetPanelURL(req.PanelURL); err != nil { + logger.Warningf("Failed to save panel URL: %v", err) + } else { + // Update log pusher with new panel URL and API key + nodeLogs.SetPanelURL(req.PanelURL) + nodeLogs.UpdateApiKey(req.ApiKey) // Update API key in log pusher + } + } else { + // Even if panel URL is not provided, update API key in log pusher + nodeLogs.UpdateApiKey(req.ApiKey) + } + + // Save node address if provided + if req.NodeAddress != "" { + if err := nodeConfig.SetNodeAddress(req.NodeAddress); err != nil { + logger.Warningf("Failed to save node address: %v", err) + } + } + + logger.Infof("Node registered successfully with API key (length: %d)", len(req.ApiKey)) + c.JSON(http.StatusOK, gin.H{ + "message": "Node registered successfully", + "apiKey": req.ApiKey, // Return API key for confirmation + }) +} diff --git a/node/config/config.go b/node/config/config.go new file mode 100644 index 00000000..e59bd811 --- /dev/null +++ b/node/config/config.go @@ -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 +} diff --git a/node/docker-compose.yml b/node/docker-compose.yml new file mode 100644 index 00000000..72459803 --- /dev/null +++ b/node/docker-compose.yml @@ -0,0 +1,64 @@ +services: + node: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node + restart: unless-stopped + environment: +# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} + #- NODE_API_KEY=test-key + - PANEL_URL=http://192.168.0.7:2054 + ports: + - "8080:8080" + - "44000:44000" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network + node2: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node2 + restart: unless-stopped + environment: +# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} + #- NODE_API_KEY=test-key1 + - PANEL_URL=http://192.168.0.7:2054 + ports: + - "8081:8080" + - "44001:44001" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network + + node3: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node3 + restart: unless-stopped + environment: + #- NODE_API_KEY=test-key + - PANEL_URL=http://192.168.0.7:2054 + ports: + - "8082:8080" + - "44002:44002" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network +networks: + xray-network: + driver: bridge diff --git a/node/logs/pusher.go b/node/logs/pusher.go new file mode 100644 index 00000000..1da305fb --- /dev/null +++ b/node/logs/pusher.go @@ -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() + } +} diff --git a/node/main.go b/node/main.go new file mode 100644 index 00000000..d64bef8e --- /dev/null +++ b/node/main.go @@ -0,0 +1,114 @@ +// Package main is the entry point for the 3x-ui node service (worker). +// This service runs XRAY Core and provides a REST API for the master panel to manage it. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/node/api" + nodeConfig "github.com/mhsanaei/3x-ui/v2/node/config" + nodeLogs "github.com/mhsanaei/3x-ui/v2/node/logs" + "github.com/mhsanaei/3x-ui/v2/node/xray" + "github.com/op/go-logging" +) + + +func main() { + var port int + var apiKey string + flag.IntVar(&port, "port", 8080, "API server port") + flag.StringVar(&apiKey, "api-key", "", "API key for authentication (optional, can be set via registration)") + flag.Parse() + + logger.InitLogger(logging.INFO) + + // Initialize node configuration system + // Try to find config directory (same as XRAY config) + configDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"} + var configDir string + for _, dir := range configDirs { + if _, err := os.Stat(dir); err == nil { + configDir = dir + break + } + } + if configDir == "" { + configDir = "." // Fallback + } + + if err := nodeConfig.InitConfig(configDir); err != nil { + log.Fatalf("Failed to initialize node config: %v", err) + } + + // Get API key from (in order of priority): + // 1. Command line flag + // 2. Environment variable (for backward compatibility) + // 3. Saved config file (from registration) + if apiKey == "" { + apiKey = os.Getenv("NODE_API_KEY") + } + if apiKey == "" { + // Try to load from saved config + savedConfig := nodeConfig.GetConfig() + if savedConfig.ApiKey != "" { + apiKey = savedConfig.ApiKey + log.Printf("Using API key from saved configuration") + } + } + + // If still no API key, node can start but will need registration + if apiKey == "" { + log.Printf("WARNING: No API key found. Node will need to be registered via /api/v1/register endpoint") + log.Printf("You can set NODE_API_KEY environment variable or use -api-key flag for immediate use") + // Use a temporary key that will be replaced during registration + apiKey = "temp-unregistered" + } + + // Initialize log pusher if panel URL is configured + // Get node address from saved config or environment variable + savedConfig := nodeConfig.GetConfig() + nodeAddress := savedConfig.NodeAddress + if nodeAddress == "" { + nodeAddress = os.Getenv("NODE_ADDRESS") + } + if nodeAddress == "" { + // Default to localhost with the port (panel will match by port if address doesn't match exactly) + nodeAddress = fmt.Sprintf("http://127.0.0.1:%d", port) + } + + // Get panel URL from saved config or environment variable + panelURL := savedConfig.PanelURL + if panelURL == "" { + panelURL = os.Getenv("PANEL_URL") + } + + nodeLogs.InitLogPusher(nodeAddress) + if panelURL != "" { + nodeLogs.SetPanelURL(panelURL) + } + // Connect log pusher to logger + logger.SetLogPusher(nodeLogs.PushLog) + + xrayManager := xray.NewManager() + server := api.NewServer(port, apiKey, xrayManager) + + log.Printf("Starting 3x-ui Node Service on port %d", port) + if err := server.Start(); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + log.Println("Shutting down...") + xrayManager.Stop() + server.Stop() + log.Println("Shutdown complete") +} diff --git a/node/xray/manager.go b/node/xray/manager.go new file mode 100644 index 00000000..c8ced5f3 --- /dev/null +++ b/node/xray/manager.go @@ -0,0 +1,541 @@ +// Package xray provides XRAY Core management for the node service. +package xray + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/json_util" + "github.com/mhsanaei/3x-ui/v2/xray" +) + +// NodeStats represents traffic and online clients statistics from a node. +type NodeStats struct { + Traffic []*xray.Traffic `json:"traffic"` + ClientTraffic []*xray.ClientTraffic `json:"clientTraffic"` + OnlineClients []string `json:"onlineClients"` +} + +// Manager manages the XRAY Core process lifecycle. +type Manager struct { + process *xray.Process + lock sync.Mutex + config *xray.Config +} + +// NewManager creates a new XRAY manager instance. +func NewManager() *Manager { + m := &Manager{} + // Download geo files if missing + m.downloadGeoFiles() + // Try to load config from file on startup + m.LoadConfigFromFile() + return m +} + +// downloadGeoFiles downloads geo data files if they are missing. +// These files are required for routing rules that use geoip/geosite matching. +func (m *Manager) downloadGeoFiles() { + // Possible bin folder paths (in order of priority) + binPaths := []string{ + "bin", + "/app/bin", + "./bin", + } + + var binPath string + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + binPath = path + break + } + } + + if binPath == "" { + logger.Debug("No bin folder found, skipping geo files download") + return + } + + // List of geo files to download + geoFiles := []struct { + URL string + FileName string + }{ + {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"}, + {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"}, + {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"}, + {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"}, + {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"}, + {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"}, + } + + downloadFile := func(url, destPath string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %d", resp.StatusCode) + } + + file, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil + } + + for _, file := range geoFiles { + destPath := filepath.Join(binPath, file.FileName) + + // Check if file already exists + if _, err := os.Stat(destPath); err == nil { + logger.Debugf("Geo file %s already exists, skipping download", file.FileName) + continue + } + + logger.Infof("Downloading geo file: %s", file.FileName) + if err := downloadFile(file.URL, destPath); err != nil { + logger.Warningf("Failed to download %s: %v", file.FileName, err) + } else { + logger.Infof("Successfully downloaded %s", file.FileName) + } + } +} + +// LoadConfigFromFile attempts to load XRAY configuration from config.json file. +// It checks multiple possible locations: bin/config.json, config/config.json, and ./config.json +func (m *Manager) LoadConfigFromFile() error { + // Possible config file paths (in order of priority) + configPaths := []string{ + "bin/config.json", + "config/config.json", + "./config.json", + "/app/bin/config.json", + "/app/config/config.json", + } + + var configData []byte + var configPath string + + // Try each path until we find a valid config file + for _, path := range configPaths { + if _, statErr := os.Stat(path); statErr == nil { + var readErr error + configData, readErr = os.ReadFile(path) + if readErr == nil { + configPath = path + break + } + } + } + + // If no config file found, that's okay - node will wait for config from panel + if configPath == "" { + logger.Debug("No config.json found, node will wait for configuration from panel") + return nil + } + + // Validate JSON + var configJSON json.RawMessage + if err := json.Unmarshal(configData, &configJSON); err != nil { + logger.Warningf("Config file %s contains invalid JSON: %v", configPath, err) + return fmt.Errorf("invalid JSON in config file: %w", err) + } + + // Parse full config + var config xray.Config + if err := json.Unmarshal(configData, &config); err != nil { + logger.Warningf("Failed to parse config from %s: %v", configPath, err) + return fmt.Errorf("failed to parse config: %w", err) + } + + // Check if API inbound exists, if not add it + hasAPIInbound := false + for _, inbound := range config.InboundConfigs { + if inbound.Tag == "api" { + hasAPIInbound = true + break + } + } + + // If no API inbound found, add a default one + if !hasAPIInbound { + logger.Debug("No API inbound found in config, adding default API inbound") + apiInbound := xray.InboundConfig{ + Tag: "api", + Port: 62789, // Default API port + Protocol: "tunnel", + Listen: json_util.RawMessage(`"127.0.0.1"`), + Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`), + } + // Add API inbound at the beginning + config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...) + // Update configData with the new inbound + configData, _ = json.MarshalIndent(&config, "", " ") + } + + // Check if config has inbounds (after adding API inbound) + if len(config.InboundConfigs) == 0 { + logger.Debug("Config file found but no inbounds configured, skipping XRAY start") + return nil + } + + // Apply the loaded config (this will start XRAY) + logger.Infof("Loading XRAY configuration from %s", configPath) + if err := m.ApplyConfig(configData); err != nil { + logger.Errorf("Failed to apply config from file: %v", err) + return fmt.Errorf("failed to apply config: %w", err) + } + + logger.Info("XRAY started successfully from config file") + return nil +} + +// IsRunning returns true if XRAY is currently running. +func (m *Manager) IsRunning() bool { + m.lock.Lock() + defer m.lock.Unlock() + return m.process != nil && m.process.IsRunning() +} + +// GetStatus returns the current status of XRAY. +func (m *Manager) GetStatus() map[string]interface{} { + m.lock.Lock() + defer m.lock.Unlock() + + status := map[string]interface{}{ + "running": m.process != nil && m.process.IsRunning(), + "version": "Unknown", + "uptime": 0, + } + + if m.process != nil && m.process.IsRunning() { + status["version"] = m.process.GetVersion() + status["uptime"] = m.process.GetUptime() + } + + return status +} + +// ApplyConfig applies a new XRAY configuration and restarts if needed. +func (m *Manager) ApplyConfig(configJSON []byte) error { + m.lock.Lock() + defer m.lock.Unlock() + + var newConfig xray.Config + if err := json.Unmarshal(configJSON, &newConfig); err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + // If XRAY is running and config is the same, skip restart + if m.process != nil && m.process.IsRunning() { + oldConfig := m.process.GetConfig() + if oldConfig != nil && oldConfig.Equals(&newConfig) { + logger.Info("Config unchanged, skipping restart") + return nil + } + // Stop existing process + if err := m.process.Stop(); err != nil { + logger.Warningf("Failed to stop existing XRAY: %v", err) + } + } + + // Start new process with new config + m.config = &newConfig + m.process = xray.NewProcess(&newConfig) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to start XRAY: %w", err) + } + + logger.Info("XRAY configuration applied successfully") + return nil +} + +// Reload reloads XRAY configuration without full restart (if supported). +// Falls back to restart if reload is not available. +func (m *Manager) Reload() error { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return errors.New("XRAY is not running") + } + + // XRAY doesn't support hot reload, so we need to restart + // Save current config + if m.config == nil { + return errors.New("no config to reload") + } + + // Stop and restart + if err := m.process.Stop(); err != nil { + return fmt.Errorf("failed to stop XRAY: %w", err) + } + + m.process = xray.NewProcess(m.config) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to restart XRAY: %w", err) + } + + logger.Info("XRAY reloaded successfully") + return nil +} + +// ForceReload forcefully reloads XRAY even if it's not running or hung. +// It stops XRAY if running, loads config from file if available, and restarts. +func (m *Manager) ForceReload() error { + m.lock.Lock() + defer m.lock.Unlock() + + // Stop XRAY if it's running (even if hung) + if m.process != nil { + // Try to stop gracefully, but don't fail if it's hung + _ = m.process.Stop() + // Give it a moment to stop + time.Sleep(500 * time.Millisecond) + // Force kill if still running + if m.process.IsRunning() { + logger.Warning("XRAY process appears hung, forcing stop") + // Process will be cleaned up by finalizer or on next start + } + m.process = nil + } + + // Try to load config from file first (if available) + configPaths := []string{ + "bin/config.json", + "config/config.json", + "./config.json", + "/app/bin/config.json", + "/app/config/config.json", + } + + var configData []byte + var configPath string + + // Find config file + for _, path := range configPaths { + if _, statErr := os.Stat(path); statErr == nil { + var readErr error + configData, readErr = os.ReadFile(path) + if readErr == nil { + configPath = path + break + } + } + } + + // If config file found, try to use it + if configPath != "" { + var config xray.Config + if err := json.Unmarshal(configData, &config); err == nil { + // Check if config has inbounds + if len(config.InboundConfigs) > 0 { + // Check if API inbound exists + hasAPIInbound := false + for _, inbound := range config.InboundConfigs { + if inbound.Tag == "api" { + hasAPIInbound = true + break + } + } + + // Add API inbound if missing + if !hasAPIInbound { + apiInbound := xray.InboundConfig{ + Tag: "api", + Port: 62789, + Protocol: "tunnel", + Listen: json_util.RawMessage(`"127.0.0.1"`), + Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`), + } + config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...) + configData, _ = json.MarshalIndent(&config, "", " ") + } + + // Apply config from file + m.config = &config + m.process = xray.NewProcess(&config) + if err := m.process.Start(); err == nil { + logger.Infof("XRAY force reloaded successfully from config file %s", configPath) + return nil + } + } + } + // If loading from file failed, continue with saved config + } + + // If no config file, try to use saved config + if m.config == nil { + return errors.New("no config available to reload") + } + + // Restart with saved config + m.process = xray.NewProcess(m.config) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to restart XRAY: %w", err) + } + + logger.Info("XRAY force reloaded successfully") + return nil +} + +// Stop stops the XRAY process. +func (m *Manager) Stop() error { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return nil + } + + return m.process.Stop() +} + +// GetStats returns traffic and online clients statistics from XRAY. +func (m *Manager) GetStats(reset bool) (*NodeStats, error) { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return nil, errors.New("XRAY is not running") + } + + // Get API port from process + apiPort := m.process.GetAPIPort() + if apiPort == 0 { + return nil, errors.New("XRAY API port is not available") + } + + // Create XrayAPI instance and initialize + xrayAPI := &xray.XrayAPI{} + if err := xrayAPI.Init(apiPort); err != nil { + return nil, fmt.Errorf("failed to initialize XrayAPI: %w", err) + } + defer xrayAPI.Close() + + // Get traffic statistics + traffics, clientTraffics, err := xrayAPI.GetTraffic(reset) + if err != nil { + return nil, fmt.Errorf("failed to get traffic: %w", err) + } + + // Get online clients from process + onlineClients := m.process.GetOnlineClients() + + // Also check online clients from traffic (clients with traffic > 0) + onlineFromTraffic := make(map[string]bool) + for _, ct := range clientTraffics { + if ct.Up+ct.Down > 0 { + onlineFromTraffic[ct.Email] = true + } + } + + // Merge online clients + onlineSet := make(map[string]bool) + for _, email := range onlineClients { + onlineSet[email] = true + } + for email := range onlineFromTraffic { + onlineSet[email] = true + } + + onlineList := make([]string, 0, len(onlineSet)) + for email := range onlineSet { + onlineList = append(onlineList, email) + } + + return &NodeStats{ + Traffic: traffics, + ClientTraffic: clientTraffics, + OnlineClients: onlineList, + }, nil +} + +// GetLogs returns XRAY access logs from the log file. +// Returns raw log lines as strings. +func (m *Manager) GetLogs(count int, filter string) ([]string, error) { + m.lock.Lock() + defer m.lock.Unlock() + + if m.process == nil || !m.process.IsRunning() { + return nil, errors.New("XRAY is not running") + } + + // Get access log path from current config + var pathToAccessLog string + if m.config != nil && len(m.config.LogConfig) > 0 { + var logConfig map[string]interface{} + if err := json.Unmarshal(m.config.LogConfig, &logConfig); err == nil { + if access, ok := logConfig["access"].(string); ok { + pathToAccessLog = access + } + } + } + + // Fallback to reading from file if not in config + if pathToAccessLog == "" { + var err error + pathToAccessLog, err = xray.GetAccessLogPath() + if err != nil { + return nil, fmt.Errorf("failed to get access log path: %w", err) + } + } + + if pathToAccessLog == "none" || pathToAccessLog == "" { + return []string{}, nil // No logs configured + } + + file, err := os.Open(pathToAccessLog) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.Contains(line, "api -> api") { + continue // Skip empty lines and API calls + } + + if filter != "" && !strings.Contains(line, filter) { + continue // Apply filter if provided + } + + lines = append(lines, line) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read log file: %w", err) + } + + // Return last 'count' lines + if len(lines) > count { + lines = lines[len(lines)-count:] + } + + return lines, nil +} \ No newline at end of file diff --git a/sub/subController.go b/sub/subController.go index ec574d6e..2ddf1fe4 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v2/config" + service "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/gin-gonic/gin" ) @@ -40,6 +41,10 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) + // Initialize services for multi-node support and new architecture + sub.nodeService = service.NodeService{} + sub.hostService = service.HostService{} + sub.clientService = service.ClientService{} a := &SUBController{ subTitle: subTitle, subPath: subPath, @@ -70,7 +75,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) subs(c *gin.Context) { subId := c.Param("subid") scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) - subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) + subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host, c) // Pass context for HWID registration if err != nil || len(subs) == 0 { c.String(400, "Error!") } else { @@ -127,7 +132,7 @@ func (a *SUBController) subs(c *gin.Context) { // Add headers header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) if a.subEncrypt { c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) @@ -141,21 +146,24 @@ func (a *SUBController) subs(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) { subId := c.Param("subid") _, host, _, _ := a.subService.ResolveRequest(c) - jsonSub, header, err := a.subJsonService.GetJson(subId, host) + jsonSub, header, err := a.subJsonService.GetJson(subId, host, c) // Pass context for HWID registration if err != nil || len(jsonSub) == 0 { c.String(400, "Error!") } else { // Add headers - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) c.String(200, jsonSub) } } // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. -func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) { +// Also adds X-Subscription-ID header so clients can use it as HWID if needed. +func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle, subId string) { c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Profile-Update-Interval", updateInterval) c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle))) + // Add subscription ID header so clients can use it as HWID identifier + c.Writer.Header().Set("X-Subscription-ID", subId) } diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 8222491a..ff043dc5 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -7,6 +7,8 @@ import ( "maps" "strings" + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/json_util" @@ -71,7 +73,19 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string, } // GetJson generates a JSON subscription configuration for the given subscription ID and host. -func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers. +func (s *SubJsonService) GetJson(subId string, host string, c *gin.Context) (string, string, error) { + // Register HWID from headers if context is provided + if c != nil { + // Try to find client by subId + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + if err == nil && clientEntity != nil { + s.SubService.registerHWIDFromRequest(c, clientEntity) + } + } + inbounds, err := s.SubService.getInboundsBySubId(subId) if err != nil || len(inbounds) == 0 { return "", "", err diff --git a/sub/subService.go b/sub/subService.go index e046ebb4..a878a2ca 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -28,6 +28,10 @@ type SubService struct { datepicker string inboundService service.InboundService settingService service.SettingService + nodeService service.NodeService + hostService service.HostService + clientService service.ClientService + hwidService service.ClientHWIDService } // NewSubService creates a new subscription service with the given configuration. @@ -39,12 +43,64 @@ func NewSubService(showInfo bool, remarkModel string) *SubService { } // GetSubs retrieves subscription links for a given subscription ID and host. -func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers (x-hwid, x-device-os, etc.). +func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]string, int64, xray.ClientTraffic, error) { s.address = host var result []string var traffic xray.ClientTraffic var lastOnline int64 var clientTraffics []xray.ClientTraffic + + // Try to find client by subId in new architecture (ClientEntity) + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + useNewArchitecture := (err == nil && clientEntity != nil) + + if err != nil { + logger.Debugf("GetSubs: Client not found by subId '%s': %v", subId, err) + } else if clientEntity != nil { + logger.Debugf("GetSubs: Found client by subId '%s': clientId=%d, email=%s, hwidEnabled=%v", + subId, clientEntity.Id, clientEntity.Email, clientEntity.HWIDEnabled) + + // Check traffic limits and expiry time before returning subscription + // Traffic statistics are now stored directly in ClientEntity + now := time.Now().Unix() * 1000 + totalUsed := clientEntity.Up + clientEntity.Down + trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024) + trafficExceeded := clientEntity.TotalGB > 0 && totalUsed >= trafficLimit + timeExpired := clientEntity.ExpiryTime > 0 && clientEntity.ExpiryTime <= now + + // Check if client exceeded limits - set status but keep Enable = true to allow subscription + if trafficExceeded || timeExpired { + // Client exceeded limits - set status but keep Enable = true + // Subscription should still work to show traffic information to client + status := "expired_traffic" + if timeExpired { + status = "expired_time" + } + + // Update status if not already set + if clientEntity.Status != status { + db.Model(&model.ClientEntity{}).Where("id = ?", clientEntity.Id).Update("status", status) + clientEntity.Status = status + logger.Warningf("GetSubs: Client %s (subId: %s) exceeded limits - set status to %s: trafficExceeded=%v, timeExpired=%v, totalUsed=%d, total=%d", + clientEntity.Email, subId, status, trafficExceeded, timeExpired, totalUsed, trafficLimit) + } + // Continue to generate subscription - client will be blocked in Xray config, not in subscription + } + + // Note: We don't block subscription even if client has expired status + // Subscription provides traffic information, and client blocking is handled in Xray config + } + + // Register HWID from headers if context is provided and client is found + if c != nil && clientEntity != nil { + s.registerHWIDFromRequest(c, clientEntity) + } else if c != nil { + logger.Debugf("GetSubs: Skipping HWID registration - client not found or context is nil (subId: %s)", subId) + } + inbounds, err := s.getInboundsBySubId(subId) if err != nil { return nil, 0, traffic, err @@ -58,14 +114,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C if err != nil { s.datepicker = "gregorian" } + for _, inbound := range inbounds { - clients, err := s.inboundService.GetClients(inbound) - if err != nil { - logger.Error("SubService - GetClients: Unable to get clients from inbound") - } - if clients == nil { - continue - } if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings) if err == nil { @@ -74,14 +124,93 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C inbound.StreamSettings = streamSettings } } - for _, client := range clients { - if client.Enable && client.SubID == subId { - link := s.getLink(inbound, client.Email) - result = append(result, link) - ct := s.getClientTraffics(inbound.ClientStats, client.Email) - clientTraffics = append(clientTraffics, ct) - if ct.LastOnline > lastOnline { - lastOnline = ct.LastOnline + + if useNewArchitecture { + // New architecture: use ClientEntity data directly + link := s.getLinkWithClient(inbound, clientEntity) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) + } + } + // Create ClientTraffic from ClientEntity for statistics (traffic is stored in ClientEntity now) + trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024) + ct := xray.ClientTraffic{ + Email: clientEntity.Email, + Up: clientEntity.Up, + Down: clientEntity.Down, + Total: trafficLimit, + ExpiryTime: clientEntity.ExpiryTime, + LastOnline: clientEntity.LastOnline, + } + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } + } else { + // Old architecture: parse clients from Settings + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + logger.Error("SubService - GetClients: Unable to get clients from inbound") + } + if clients == nil { + continue + } + for _, client := range clients { + if client.Enable && client.SubID == subId { + // Use ClientEntity for traffic (new architecture only) + var clientEntity model.ClientEntity + err = db.Where("LOWER(email) = ?", strings.ToLower(client.Email)).First(&clientEntity).Error + if err != nil { + // Client not found in ClientEntity - skip (old architecture clients should be migrated) + logger.Warningf("GetSubs: Client %s (subId: %s) not found in ClientEntity - skipping", + client.Email, subId) + continue + } + + // Check traffic limits from ClientEntity + now := time.Now().Unix() * 1000 + totalUsed := clientEntity.Up + clientEntity.Down + trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024) + trafficExceeded := clientEntity.TotalGB > 0 && totalUsed >= trafficLimit + timeExpired := clientEntity.ExpiryTime > 0 && clientEntity.ExpiryTime <= now + + if trafficExceeded || timeExpired || !clientEntity.Enable { + logger.Warningf("GetSubs: Client %s (subId: %s) exceeded limits or disabled - skipping", + client.Email, subId) + continue + } + + // Create ClientTraffic from ClientEntity for statistics + clientTraffic := xray.ClientTraffic{ + Email: clientEntity.Email, + Up: clientEntity.Up, + Down: clientEntity.Down, + Total: trafficLimit, + ExpiryTime: clientEntity.ExpiryTime, + LastOnline: clientEntity.LastOnline, + } + + link := s.getLink(inbound, client.Email) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) + } + } + ct := s.getClientTraffics(inbound.ClientStats, client.Email) + if ct.Email == "" { + ct = clientTraffic + } + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } } } } @@ -112,10 +241,45 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C return result, lastOnline, traffic, nil } +// getInboundsBySubId retrieves all inbounds assigned to a client with the given subId. +// New architecture: Find client by subId, then find inbounds through ClientInboundMapping. func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { db := database.GetDB() + + // First, try to find client by subId in ClientEntity (new architecture) + var client model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&client).Error + if err == nil { + // Found client in new architecture, get inbounds through mapping + var mappings []model.ClientInboundMapping + err = db.Where("client_id = ?", client.Id).Find(&mappings).Error + if err != nil { + return nil, err + } + + if len(mappings) == 0 { + return []*model.Inbound{}, nil + } + + inboundIds := make([]int, len(mappings)) + for i, mapping := range mappings { + inboundIds[i] = mapping.InboundId + } + + var inbounds []*model.Inbound + err = db.Model(model.Inbound{}).Preload("ClientStats"). + Where("id IN ? AND enable = ? AND protocol IN ?", + inboundIds, true, []model.Protocol{model.VMESS, model.VLESS, model.Trojan, model.Shadowsocks}). + Find(&inbounds).Error + if err != nil { + return nil, err + } + return inbounds, nil + } + + // Fallback to old architecture: search in Settings JSON (for backward compatibility) var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( + err = db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( SELECT DISTINCT inbounds.id FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client @@ -175,82 +339,145 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string { return "" } +// getLinkWithClient generates a subscription link using ClientEntity data (new architecture) +func (s *SubService) getLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + switch inbound.Protocol { + case "vmess": + return s.genVmessLinkWithClient(inbound, client) + case "vless": + return s.genVlessLinkWithClient(inbound, client) + case "trojan": + return s.genTrojanLinkWithClient(inbound, client) + case "shadowsocks": + return s.genShadowsocksLinkWithClient(inbound, client) + } + return "" +} + +// AddressPort represents an address and port for subscription links +type AddressPort struct { + Address string + Port int // 0 means use inbound.Port +} + +// getAddressesForInbound returns addresses for subscription links. +// Priority: Host (if enabled) > Node addresses > default address +// Returns addresses and ports (0 means use inbound.Port) +func (s *SubService) getAddressesForInbound(inbound *model.Inbound) []AddressPort { + // First, check if there's a Host assigned to this inbound + host, err := s.hostService.GetHostForInbound(inbound.Id) + if err == nil && host != nil && host.Enable { + // Use host address and port + hostPort := host.Port + if hostPort > 0 { + return []AddressPort{{Address: host.Address, Port: hostPort}} + } + return []AddressPort{{Address: host.Address, Port: 0}} // 0 means use inbound.Port + } + + // Second, get node addresses if in multi-node mode + var nodeAddresses []AddressPort + multiMode, _ := s.settingService.GetMultiNodeMode() + if multiMode { + nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) + if err == nil && len(nodes) > 0 { + // Extract addresses from all nodes + for _, node := range nodes { + nodeAddr := s.extractNodeHost(node.Address) + if nodeAddr != "" { + nodeAddresses = append(nodeAddresses, AddressPort{Address: nodeAddr, Port: 0}) + } + } + } + } + + // Fallback to default logic if no nodes found + if len(nodeAddresses) == 0 { + var defaultAddress string + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + defaultAddress = s.address + } else { + defaultAddress = inbound.Listen + } + nodeAddresses = []AddressPort{{Address: defaultAddress, Port: 0}} + } + + return nodeAddresses +} + func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VMESS { return "" } - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } - obj := map[string]any{ + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + // Base object template (address will be set per node) + baseObj := map[string]any{ "v": "2", - "add": address, "port": inbound.Port, "type": "none", } var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) network, _ := stream["network"].(string) - obj["net"] = network + baseObj["net"] = network switch network { case "tcp": tcp, _ := stream["tcpSettings"].(map[string]any) header, _ := tcp["header"].(map[string]any) typeStr, _ := header["type"].(string) - obj["type"] = typeStr + baseObj["type"] = typeStr if typeStr == "http" { request := header["request"].(map[string]any) requestPath, _ := request["path"].([]any) - obj["path"] = requestPath[0].(string) + baseObj["path"] = requestPath[0].(string) headers, _ := request["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "kcp": kcp, _ := stream["kcpSettings"].(map[string]any) header, _ := kcp["header"].(map[string]any) - obj["type"], _ = header["type"].(string) - obj["path"], _ = kcp["seed"].(string) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) case "ws": ws, _ := stream["wsSettings"].(map[string]any) - obj["path"] = ws["path"].(string) + baseObj["path"] = ws["path"].(string) if host, ok := ws["host"].(string); ok && len(host) > 0 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := ws["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "grpc": grpc, _ := stream["grpcSettings"].(map[string]any) - obj["path"] = grpc["serviceName"].(string) - obj["authority"] = grpc["authority"].(string) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) if grpc["multiMode"].(bool) { - obj["type"] = "multi" + baseObj["type"] = "multi" } case "httpupgrade": httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) - obj["path"] = httpupgrade["path"].(string) + baseObj["path"] = httpupgrade["path"].(string) if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := httpupgrade["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } case "xhttp": xhttp, _ := stream["xhttpSettings"].(map[string]any) - obj["path"] = xhttp["path"].(string) + baseObj["path"] = xhttp["path"].(string) if host, ok := xhttp["host"].(string); ok && len(host) > 0 { - obj["host"] = host + baseObj["host"] = host } else { headers, _ := xhttp["headers"].(map[string]any) - obj["host"] = searchHost(headers) + baseObj["host"] = searchHost(headers) } - obj["mode"] = xhttp["mode"].(string) + baseObj["mode"] = xhttp["mode"].(string) } security, _ := stream["security"].(string) - obj["tls"] = security + baseObj["tls"] = security if security == "tls" { tlsSetting, _ := stream["tlsSettings"].(map[string]any) alpns, _ := tlsSetting["alpn"].([]any) @@ -259,19 +486,19 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, a := range alpns { alpn = append(alpn, a.(string)) } - obj["alpn"] = strings.Join(alpn, ",") + baseObj["alpn"] = strings.Join(alpn, ",") } if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - obj["sni"], _ = sniValue.(string) + baseObj["sni"], _ = sniValue.(string) } tlsSettings, _ := searchKey(tlsSetting, "settings") if tlsSetting != nil { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - obj["fp"], _ = fpValue.(string) + baseObj["fp"], _ = fpValue.(string) } if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { - obj["allowInsecure"], _ = insecure.(bool) + baseObj["allowInsecure"], _ = insecure.(bool) } } } @@ -284,18 +511,22 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { break } } - obj["id"] = clients[clientIndex].ID - obj["scy"] = clients[clientIndex].Security + baseObj["id"] = clients[clientIndex].ID + baseObj["scy"] = clients[clientIndex].Security externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) newObj := map[string]any{} - for key, value := range obj { + for key, value := range baseObj { if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { newObj[key] = value } @@ -307,32 +538,419 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if newSecurity != "same" { newObj["tls"] = newSecurity } - if index > 0 { + if linkIndex > 0 { links += "\n" } jsonStr, _ := json.MarshalIndent(newObj, "", " ") links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ } return links } - obj["ps"] = s.genRemark(inbound, email, "") + // Generate links for each node address + for _, addrPort := range nodeAddresses { + obj := make(map[string]any) + for k, v := range baseObj { + obj[k] = v + } + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } + obj["ps"] = s.genRemark(inbound, email, "") - jsonStr, _ := json.MarshalIndent(obj, "", " ") - return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(obj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + + return links } -func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen +// genVmessLinkWithClient generates VMESS link using ClientEntity data (new architecture) +func (s *SubService) genVmessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + // Base object template (address will be set per node) + baseObj := map[string]any{ + "v": "2", + "port": inbound.Port, + "type": "none", + } + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + network, _ := stream["network"].(string) + baseObj["net"] = network + switch network { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + baseObj["type"] = typeStr + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + baseObj["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + baseObj["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + baseObj["type"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + baseObj["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + baseObj["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + baseObj["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + baseObj["tls"] = security + if security == "tls" { + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + if len(alpns) > 0 { + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + baseObj["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + baseObj["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + baseObj["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + baseObj["allowInsecure"], _ = insecure.(bool) + } + } } + // Use ClientEntity data directly + baseObj["id"] = client.UUID + baseObj["scy"] = client.Security + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + newObj := map[string]any{} + for key, value := range baseObj { + if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { + newObj[key] = value + } + } + newObj["ps"] = s.genRemark(inbound, client.Email, ep["remark"].(string)) + newObj["add"] = ep["dest"].(string) + newObj["port"] = int(ep["port"].(float64)) + + if newSecurity != "same" { + newObj["tls"] = newSecurity + } + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(newObj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + return links + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + obj := make(map[string]any) + for k, v := range baseObj { + obj[k] = v + } + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } + obj["ps"] = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(obj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + + return links +} + +// genVlessLinkWithClient generates VLESS link using ClientEntity data (new architecture) +func (s *SubService) genVlessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { if inbound.Protocol != model.VLESS { return "" } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + uuid := client.UUID + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + // Add encryption parameter for VLESS from inbound settings + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + if encryption, ok := settings["encryption"].(string); ok { + params["encryption"] = encryption + } + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + links = append(links, url.String()) + } + return strings.Join(links, "\n") + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + links = append(links, url.String()) + } + + return strings.Join(links, "\n") +} + +func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.VLESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -483,14 +1101,24 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + // Pre-allocate capacity based on external proxies or node addresses + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := make([]string, 0, len(externalProxies)) for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) if newSecurity != "same" { params["security"] = newSecurity @@ -516,31 +1144,241 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { return strings.Join(links, "\n") } - link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port) - url, _ := url.Parse(link) - q := url.Query() + // Generate links for each node address + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() - for k, v := range params { - q.Add(k, v) + for k, v := range params { + q.Add(k, v) + } + + // Set the new query values on the URL + url.RawQuery = q.Encode() + + url.Fragment = s.genRemark(inbound, email, "") + + links = append(links, url.String()) } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return strings.Join(links, "\n") } -func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } +// genTrojanLinkWithClient generates Trojan link using ClientEntity data (new architecture) +func (s *SubService) genTrojanLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { if inbound.Protocol != model.Trojan { return "" } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + password := client.Password + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + +func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.Trojan { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -680,14 +1518,18 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) if newSecurity != "same" { params["security"] = newSecurity @@ -708,40 +1550,225 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } return links } - link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port) + // Generate links for each node address + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() - url, _ := url.Parse(link) - q := url.Query() + for k, v := range params { + q.Add(k, v) + } - for k, v := range params { - q.Add(k, v) + // Set the new query values on the URL + url.RawQuery = q.Encode() + + url.Fragment = s.genRemark(inbound, email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } -func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } +// genShadowsocksLinkWithClient generates Shadowsocks link using ClientEntity data (new architecture) +func (s *SubService) genShadowsocksLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { if inbound.Protocol != model.Shadowsocks { return "" } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + inboundPassword := settings["password"].(string) + method := settings["method"].(string) + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + encPart := fmt.Sprintf("%s:%s", method, client.Password) + if method[0] == '2' { + encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, client.Password) + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + +func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.Shadowsocks { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -852,14 +1879,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st externalProxies, _ := stream["externalProxy"].([]any) + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { + for _, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) if newSecurity != "same" { params["security"] = newSecurity @@ -880,27 +1911,43 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - if index > 0 { + if linkIndex > 0 { links += "\n" } links += url.String() + linkIndex++ } return links } - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) - url, _ := url.Parse(link) - q := url.Query() + // Generate links for each node address + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() - for k, v := range params { - q.Add(k, v) + for k, v := range params { + q.Add(k, v) + } + + // Set the new query values on the URL + url.RawQuery = q.Encode() + + url.Fragment = s.genRemark(inbound, email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + + return links } func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { @@ -1215,3 +2262,103 @@ func getHostFromXFH(s string) (string, error) { } return s, nil } + +// extractNodeHost extracts the host from a node API address. +// Example: "http://192.168.1.100:8080" -> "192.168.1.100" +func (s *SubService) extractNodeHost(nodeAddress string) string { + // Remove protocol prefix + address := strings.TrimPrefix(nodeAddress, "http://") + address = strings.TrimPrefix(address, "https://") + + // Extract host (remove port if present) + host, _, err := net.SplitHostPort(address) + if err != nil { + // No port, return as is + return address + } + return host +} + +// registerHWIDFromRequest registers HWID from HTTP headers in the request context. +// This method reads HWID and device metadata from headers and calls RegisterHWIDFromHeaders. +func (s *SubService) registerHWIDFromRequest(c *gin.Context, clientEntity *model.ClientEntity) { + logger.Debugf("registerHWIDFromRequest called for client %d (subId: %s, email: %s, hwidEnabled: %v)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, clientEntity.HWIDEnabled) + + // Check HWID mode - only register in client_header mode + settingService := service.SettingService{} + hwidMode, err := settingService.GetHwidMode() + if err != nil { + logger.Debugf("Failed to get hwidMode setting: %v", err) + return + } + logger.Debugf("Current hwidMode: %s", hwidMode) + + // Only register in client_header mode + if hwidMode != "client_header" { + logger.Debugf("HWID registration skipped: hwidMode is '%s' (not 'client_header') for client %d (subId: %s)", + hwidMode, clientEntity.Id, clientEntity.SubID) + return + } + + // Check if client has HWID tracking enabled + if !clientEntity.HWIDEnabled { + logger.Debugf("HWID registration skipped: HWID tracking disabled for client %d (subId: %s, email: %s)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read HWID from headers (required) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + // No HWID header - mark as "unknown" device, don't register + // In client_header mode, we don't auto-generate HWID + logger.Debugf("No x-hwid header provided for client %d (subId: %s, email: %s) - HWID not registered", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read device metadata from headers (optional) + 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() + + // Register HWID + hwidService := service.ClientHWIDService{} + hwidRecord, err := hwidService.RegisterHWIDFromHeaders(clientEntity.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") { + // Log as warning - this is an expected error when limit is reached + logger.Warningf("HWID limit exceeded for client %d (subId: %s, email: %s): %v", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, err) + // Note: We still allow the subscription request to proceed + // The client application should handle this error and inform the user + // that they need to remove an existing device or contact admin to increase limit + } else { + // Other errors - log as warning but don't fail subscription + logger.Warningf("Failed to register HWID for client %d (subId: %s): %v", clientEntity.Id, clientEntity.SubID, err) + } + // HWID registration failure should not block subscription access + // The subscription will still be returned, but HWID won't be registered + } else if hwidRecord != nil { + // Successfully registered HWID + logger.Debugf("Successfully registered HWID for client %d (subId: %s, email: %s, hwid: %s, hwidId: %d)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, hwid, hwidRecord.Id) + } +} diff --git a/web/assets/css/custom.min.css b/web/assets/css/custom.min.css index fcbc3a69..f6c37d2a 100644 --- a/web/assets/css/custom.min.css +++ b/web/assets/css/custom.min.css @@ -1 +1 @@ -:root{--color-primary-100:#008771;--dark-color-background:#0a1222;--dark-color-surface-100:#151f31;--dark-color-surface-200:#222d42;--dark-color-surface-300:#2c3950;--dark-color-surface-400:rgba(65, 85, 119, 0.5);--dark-color-surface-500:#2c3950;--dark-color-surface-600:#313f5a;--dark-color-surface-700:#111929;--dark-color-surface-700-rgb:17, 25, 41;--dark-color-table-hover:rgba(44, 57, 80, 0.2);--dark-color-text-primary:rgba(255, 255, 255, 0.75);--dark-color-stroke:#2c3950;--dark-color-btn-danger:#cd3838;--dark-color-btn-danger-border:transparent;--dark-color-btn-danger-hover:#e94b4b;--dark-color-tag-bg:rgba(255, 255, 255, 0.05);--dark-color-tag-border:rgba(255, 255, 255, 0.15);--dark-color-tag-color:rgba(255, 255, 255, 0.75);--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:25, 81, 65;--dark-color-tag-green-color:#3ad3ba;--dark-color-tag-purple-bg:#201425;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d988cd;--dark-color-tag-red-bg:#291515;--dark-color-tag-red-border:#5c2626;--dark-color-tag-red-color:#e04141;--dark-color-tag-orange-bg:#312313;--dark-color-tag-orange-border:#593914;--dark-color-tag-orange-color:#ffa031;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#1348ab;--dark-color-tag-blue-color:#529fff;--dark-color-codemirror-line-hover:rgba(0, 135, 113, 0.2);--dark-color-codemirror-line-selection:rgba(0, 135, 113, 0.3);--dark-color-login-background:var(--dark-color-background);--dark-color-login-wave:var(--dark-color-surface-200);--dark-color-tooltip:rgba(61, 76, 104, 0.9);--dark-color-back-top:rgba(61, 76, 104, 0.9);--dark-color-back-top-hover:rgba(61, 76, 104, 1);--dark-color-scrollbar:#313f5a;--dark-color-scrollbar-webkit:#7484a0;--dark-color-scrollbar-webkit-hover:#90a4c7;--dark-color-table-ring:rgb(38 52 77);--dark-color-spin-container:#151f31}html[data-theme-animations='off']{.ant-menu,.ant-layout-sider,.ant-card,.ant-tag,.ant-progress-circle>*,.ant-input,.ant-table-row-expand-icon,.ant-switch,.ant-table-thead>tr>th,.ant-select-selection,.ant-btn,.ant-input-number,.ant-input-group-addon,.ant-checkbox-inner,.ant-progress-bg,.ant-progress-success-bg,.ant-radio-button-wrapper:not(:first-child):before,.ant-radio-button-wrapper,#login,.cm-s-xq.CodeMirror{transition:border 0s,background 0s!important}.ant-menu.ant-menu-inline .ant-menu-item:not(.ant-menu-sub .ant-menu-item),.ant-layout-sider-trigger,.ant-alert-close-icon .anticon-close,.ant-tabs-nav .ant-tabs-tab,.ant-input-number-input,.ant-collapse>.ant-collapse-item>.ant-collapse-header,.Line-Hover,.ant-menu-theme-switch,.ant-menu-submenu-title{transition:color 0s!important}.wave-btn-bg{transition:width 0s!important}}html[data-theme='ultra-dark']{--dark-color-background:#21242a;--dark-color-surface-100:#0c0e12;--dark-color-surface-200:#222327;--dark-color-surface-300:#32353b;--dark-color-surface-400:rgba(255, 255, 255, 0.1);--dark-color-surface-500:#3b404b;--dark-color-surface-600:#505663;--dark-color-surface-700:#101113;--dark-color-surface-700-rgb:16, 17, 19;--dark-color-table-hover:rgba(89, 89, 89, 0.15);--dark-color-text-primary:rgb(255 255 255 / 85%);--dark-color-stroke:#202025;--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:29, 95, 77;--dark-color-tag-green-color:#59cbac;--dark-color-tag-purple-bg:#241121;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d686ca;--dark-color-tag-red-bg:#2a1215;--dark-color-tag-red-border:#58181c;--dark-color-tag-red-color:#e84749;--dark-color-tag-orange-bg:#2b1d11;--dark-color-tag-orange-border:#593815;--dark-color-tag-orange-color:#e89a3c;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#0f367e;--dark-color-tag-blue-color:#3c89e8;--dark-color-codemirror-line-hover:rgba(82, 84, 94, 0.2);--dark-color-codemirror-line-selection:rgba(82, 84, 94, 0.3);--dark-color-login-background:#0a2227;--dark-color-login-wave:#0f2d32;--dark-color-tooltip:rgba(88, 93, 100, 0.9);--dark-color-back-top:rgba(88, 93, 100, 0.9);--dark-color-back-top-hover:rgba(88, 93, 100, 1);--dark-color-scrollbar:rgb(107,107,107);--dark-color-scrollbar-webkit:#9f9f9f;--dark-color-scrollbar-webkit-hover:#d1d1d1;--dark-color-table-ring:rgb(37 39 42);--dark-color-spin-container:#1d1d1d;.ant-dropdown-menu-dark,.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-500)}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.dark .waves-header{background-color:#0a2227}.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-600)}}html,body{height:100vh;width:100vw;margin:0;padding:0;overflow:hidden}body{color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;background-color:#fff;font-feature-settings:"tnum"}html{--antd-wave-shadow-color:var(--color-primary-100);line-height:1.15;text-size-adjust:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-moz-tap-highlight-color:#fff0;-webkit-tap-highlight-color:#fff0}@supports (scrollbar-width:auto) and (not selector(::-webkit-scrollbar)){:not(.dark){scrollbar-color:#9a9a9a #fff0;scrollbar-width:thin}.dark *{scrollbar-color:var(--dark-color-scrollbar) #fff0;scrollbar-width:thin}}::-webkit-scrollbar{width:10px;height:10px;background-color:#fff0}::-webkit-scrollbar-track{background-color:#fff0;margin-block:.5em}.ant-modal-wrap::-webkit-scrollbar-track{background-color:#fff;margin-block:0}::-webkit-scrollbar-thumb{border-radius:9999px;background-color:#9a9a9a;border:2px solid #fff0;background-clip:content-box}::-webkit-scrollbar-thumb:hover,::-webkit-scrollbar-thumb:active{background-color:#828282}.dark .ant-modal-wrap::-webkit-scrollbar-track{background-color:var(--dark-color-background)}.dark::-webkit-scrollbar-thumb{background-color:var(--dark-color-scrollbar-webkit)}.dark::-webkit-scrollbar-thumb:hover,.dark::-webkit-scrollbar-thumb:active{background-color:var(--dark-color-scrollbar-webkit-hover)}::-moz-selection{color:var(--color-primary-100);background-color:#cfe8e4}::selection{color:var(--color-primary-100);background-color:#cfe8e4}#app{height:100%;position:fixed;top:0;left:0;right:0;bottom:0;margin:0;padding:0;overflow:auto}.ant-layout,.ant-layout *{box-sizing:border-box}.ant-spin-container:after{border-radius:1.5rem}.dark .ant-spin-container:after{background:var(--dark-color-spin-container)}style attribute{text-align:center}.ant-table-thead>tr>th{padding:12px 8px}.ant-table-tbody>tr>td{padding:10px 8px}.ant-table-thead>tr>th{color:rgb(0 0 0 / .85);font-weight:500;text-align:left;border-bottom:1px solid #e8e8e8;transition:background 0.3s ease}.ant-table table{border-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:first-child{border-bottom-left-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:last-child{border-bottom-right-radius:1rem}.ant-table{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;font-feature-settings:"tnum";position:relative;clear:both}.ant-table .ant-table-body:not(.ant-table-expanded-row .ant-table-body){overflow-x:auto!important}.ant-card-hoverable{cursor:auto;cursor:pointer}.ant-card{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;position:relative;background-color:#fff;border-radius:2px;transition:all 0.3s}.ant-space{width:100%}.ant-layout-sider-zero-width-trigger{display:none}@media (max-width:768px){.ant-layout-sider{display:none}.ant-card,.ant-alert-error{margin:.5rem}.ant-tabs{margin:.5rem;padding:.5rem}.ant-modal-body{padding:20px}.ant-form-item-label{line-height:1.5;padding:8px 0 0}:not(.dark)::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}.dark::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}}.ant-layout-content{min-height:auto}.ant-card,.ant-tabs{border-radius:1.5rem}.ant-card-hoverable{cursor:auto}.ant-card+.ant-card{margin-top:20px}.drawer-handle{position:absolute;top:72px;width:41px;height:40px;cursor:pointer;z-index:0;text-align:center;line-height:40px;font-size:16px;display:flex;justify-content:center;align-items:center;background-color:#fff;right:-40px;box-shadow:2px 0 8px rgb(0 0 0 / .15);border-radius:0 4px 4px 0}.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{background-color:#006655!important;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move linear 6.6s infinite;color:#fff;border-radius:.5rem}.ant-layout-sider-collapsed .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{border-radius:0}.ant-menu-item-active,.ant-menu-item:hover,.ant-menu-submenu-title:hover,.ant-menu-item:active,.ant-menu-submenu-title:active{color:var(--color-primary-100);background-color:#e8f4f2}.ant-menu-inline .ant-menu-item,.ant-menu-inline .ant-menu-submenu-title{border-radius:.5rem}.ant-menu-inline .ant-menu-item:after,.ant-menu{border-right-width:0}.ant-layout-sider-children,.ant-pagination ul{padding:.5rem}.ant-layout-sider-collapsed .ant-layout-sider-children{padding:.5rem 0}.ant-dropdown-menu,.ant-select-dropdown-menu{padding:.5rem}.ant-dropdown-menu-item,.ant-dropdown-menu-item:hover,.ant-select-dropdown-menu-item,.ant-select-dropdown-menu-item:hover,.ant-select-selection--multiple .ant-select-selection__choice{border-radius:.5rem}.ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item,.ant-select-dropdown--single .ant-select-dropdown-menu .ant-select-dropdown-menu-item-selected{margin-block:2px}@media (min-width:769px){.drawer-handle{display:none}.ant-tabs{padding:2rem}}.fade-in-enter,.fade-in-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active{opacity:0}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-enter-active,.fade-in-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter-active,.zoom-in-center-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter,.zoom-in-center-leave-active{opacity:0;-webkit-transform:scaleX(0);transform:scaleX(0)}.zoom-in-top-enter-active,.zoom-in-top-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center top;transform-origin:center top}.zoom-in-top-enter,.zoom-in-top-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-bottom-enter-active,.zoom-in-bottom-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center bottom;transform-origin:center bottom}.zoom-in-bottom-enter,.zoom-in-bottom-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-left-enter-active,.zoom-in-left-leave-active{opacity:1;-webkit-transform:scale(1,1);transform:scale(1,1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:top left;transform-origin:top left}.zoom-in-left-enter,.zoom-in-left-leave-active{opacity:0;-webkit-transform:scale(.45,.45);transform:scale(.45,.45)}.list-enter-active,.list-leave-active{-webkit-transition:all 0.3s;transition:all 0.3s}.list-enter,.list-leave-active{opacity:0;-webkit-transform:translateY(-30px);transform:translateY(-30px)}.ant-tooltip-inner{min-height:0;padding-inline:1rem}.ant-list-item-meta-title{font-size:14px}.ant-progress-inner{background-color:#ebeef5}.deactive-client .ant-collapse-header{color:#ffffff!important;background-color:#ff7f7f}.ant-table-expand-icon-th,.ant-table-row-expand-icon-cell{width:30px;min-width:30px}.ant-tabs{background-color:#fff}.ant-form-item{margin-bottom:0}.ant-setting-textarea{margin-top:1.5rem}.client-table-header{background-color:#f0f2f5}.client-table-odd-row{background-color:#fafafa}.ant-table-pagination.ant-pagination{float:left}.ant-tag{margin-right:0;margin-inline:2px;display:inline-flex;align-items:center;justify-content:space-evenly}.ant-tag:not(.qr-tag){column-gap:4px}#inbound-info-modal .ant-tag{margin-block:2px}.tr-info-table{display:inline-table;margin-block:10px;width:100%}#inbound-info-modal .tr-info-table .ant-tag{margin-block:0;margin-inline:0}.tr-info-row{display:flex;flex-direction:column;row-gap:2px;margin-block:10px}.tr-info-row a{margin-left:6px}.tr-info-row code{padding-inline:8px;max-height:80px;overflow-y:auto}.tr-info-tag{max-width:100%;text-wrap:balance;overflow:hidden;overflow-wrap:anywhere}.tr-info-title{display:inline-flex;align-items:center;justify-content:flex-start;column-gap:4px}.ant-tag-blue{background-color:#edf4fa;border-color:#a9c5e7;color:#0e49b5}.ant-tag-green{background-color:#eafff9;border-color:#76ccb4;color:#199270}.ant-tag-purple{background-color:#f2eaf1;border-color:#d5bed2;color:#7a316f}.ant-tag-orange,.ant-alert-warning{background-color:#ffeee1;border-color:#fec093;color:#f37b24}.ant-tag-red,.ant-alert-error{background-color:#ffe9e9;border-color:#ff9e9e;color:#cf3c3c}.ant-input::placeholder{opacity:.5}.ant-input:hover,.ant-input:focus{background-color:#e8f4f2}.ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){background-color:#e8f4f2}.delete-icon:hover{color:#e04141}.normal-icon:hover{color:var(--color-primary-100)}.dark ::-moz-selection{color:#fff;background-color:var(--color-primary-100)}.dark ::selection{color:#fff;background-color:var(--color-primary-100)}.dark .normal-icon:hover{color:#fff}.dark .ant-layout-sider,.dark .ant-drawer-content,.ant-menu-dark,.ant-menu-dark .ant-menu-sub,.dark .ant-card,.dark .ant-table,.dark .ant-collapse-content,.dark .ant-tabs{background-color:var(--dark-color-surface-100);color:var(--dark-color-text-primary)}.dark .ant-card-hoverable:hover,.dark .ant-space-item>.ant-tabs:hover{box-shadow:0 2px 8px #fff0}.dark>.ant-layout,.dark .drawer-handle,.dark .ant-table-thead>tr>th,.dark .ant-table-expanded-row,.dark .ant-table-expanded-row:hover,.dark .ant-table-expanded-row .ant-table-tbody,.dark .ant-calendar{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-table-expanded-row .ant-table-thead>tr:first-child>th{border-radius:0}.dark .ant-calendar,.dark .ant-card-bordered{border-color:var(--dark-color-background)}.dark .ant-table-bordered,.dark .ant-table-bordered.ant-table-empty .ant-table-placeholder,.dark .ant-table-bordered .ant-table-body>table,.dark .ant-table-bordered .ant-table-fixed-left table,.dark .ant-table-bordered .ant-table-fixed-right table,.dark .ant-table-bordered .ant-table-header>table,.dark .ant-table-bordered .ant-table-thead>tr:not(:last-child)>th,.dark .ant-table-bordered .ant-table-tbody>tr>td,.dark .ant-table-bordered .ant-table-thead>tr>th{border-color:var(--dark-color-surface-400)}.dark .ant-table-tbody>tr>td,.dark .ant-table-thead>tr>th,.dark .ant-card-head,.dark .ant-modal-header,.dark .ant-collapse>.ant-collapse-item,.dark .ant-tabs-bar,.dark .ant-list-split .ant-list-item,.dark .ant-popover-title,.dark .ant-calendar-header,.dark .ant-calendar-input-wrap{border-bottom-color:var(--dark-color-surface-400)}.dark .ant-modal-footer,.dark .ant-collapse-content,.dark .ant-calendar-footer,.dark .ant-divider-horizontal.ant-divider-with-text-left:before,.dark .ant-divider-horizontal.ant-divider-with-text-left:after,.dark .ant-divider-horizontal.ant-divider-with-text-center:before,.dark .ant-divider-horizontal.ant-divider-with-text-center:after{border-top-color:var(--dark-color-surface-300)}.ant-divider-horizontal.ant-divider-with-text-left:before{width:10%}.dark .ant-progress-text,.dark .ant-card-head,.dark .ant-form,.dark .ant-collapse>.ant-collapse-item>.ant-collapse-header,.dark .ant-modal-close-x,.dark .ant-form .anticon,.dark .ant-tabs-tab-arrow-show:not(.ant-tabs-tab-btn-disabled),.dark .anticon-close,.dark .ant-list-item-meta-title,.dark .ant-select-selection i,.dark .ant-modal-confirm-title,.dark .ant-modal-confirm-content,.dark .ant-popover-message,.dark .ant-modal,.dark .ant-divider-inner-text,.dark .ant-popover-title,.dark .ant-popover-inner-content,.dark h2,.dark .ant-modal-title,.dark .ant-form-item-label>label,.dark .ant-checkbox-wrapper,.dark .ant-form-item,.dark .ant-calendar-footer .ant-calendar-today-btn,.dark .ant-calendar-footer .ant-calendar-time-picker-btn,.dark .ant-calendar-day-select,.dark .ant-calendar-month-select,.dark .ant-calendar-year-select,.dark .ant-calendar-date,.dark .ant-calendar-year-panel-year,.dark .ant-calendar-month-panel-month,.dark .ant-calendar-decade-panel-decade{color:var(--dark-color-text-primary)}.dark .ant-pagination-options-size-changer .ant-select-arrow .anticon.anticon-down.ant-select-arrow-icon{color:rgb(255 255 255 / 35%)}.dark .ant-pagination-item a,.dark .ant-pagination-next a,.dark .ant-pagination-prev a{color:var(--dark-color-text-primary)}.dark .ant-pagination-item:focus a,.dark .ant-pagination-item:hover a,.dark .ant-pagination-item-active a,.dark .ant-pagination-next:hover .ant-pagination-item-link{color:var(--color-primary-100)}.dark .ant-pagination-item-active{background-color:#fff0}.dark .ant-list-item-meta-description{color:rgb(255 255 255 / .45)}.dark .ant-pagination-disabled i,.dark .ant-tabs-tab-btn-disabled{color:rgb(255 255 255 / .25)}.dark .ant-input,.dark .ant-input-group-addon,.dark .ant-collapse,.dark .ant-select-selection,.dark .ant-input-number,.dark .ant-input-number-handler-wrap,.dark .ant-table-placeholder,.dark .ant-empty-normal,.dark .ant-select-dropdown,.dark .ant-select-dropdown li,.dark .ant-select-dropdown-menu-item,.dark .client-table-header,.dark .ant-select-selection--multiple .ant-select-selection__choice{background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.dark .ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected :not(.ant-dropdown-menu-submenu-title:hover){background-color:var(--dark-color-surface-300)}.dark .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected{background-color:var(--dark-color-surface-300)}.dark .ant-calendar-time-picker-inner{background-color:var(--dark-color-background)}.dark .ant-select-selection:hover,.dark .ant-calendar-picker-clear,.dark .ant-input-number:hover,.dark .ant-input-number:focus,.dark .ant-input:hover,.dark .ant-input:focus{background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.dark .ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){border-color:var(--color-primary-100);background-color:rgb(0 135 113 / .3)}.dark .ant-btn:not(.ant-btn-primary):not(.ant-btn-danger){color:var(--dark-color-text-primary);background-color:rgb(10 117 87 / 30%);border:1px solid var(--color-primary-100)}.dark .ant-radio-button-wrapper,.dark .ant-radio-button-wrapper:before{color:var(--dark-color-text-primary);background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){background-color:#e8f4f2}.dark .ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.dark .ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){color:#fff;background-color:rgb(10 117 87 / 50%);border-color:var(--color-primary-100)}.dark .ant-btn-primary[disabled],.dark .ant-btn-danger[disabled],.dark .ant-calendar-ok-btn-disabled{color:rgb(255 255 255 / 35%);background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300)}.dark .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.dark .client-table-odd-row{background-color:var(--dark-color-table-hover)}.dark .ant-table-row-expand-icon{color:#fff;background-color:#fff0;border-color:rgb(255 255 255 / 20%)}.dark .ant-table-row-expand-icon:hover{color:var(--color-primary-100);background-color:#fff0;border-color:var(--color-primary-100)}.dark .ant-switch:not(.ant-switch-checked),.dark .ant-progress-line .ant-progress-inner{background-color:var(--dark-color-surface-500)}.dark .ant-progress-circle-trail{stroke:var(--dark-color-stroke)!important}.dark .ant-popover-inner{background-color:var(--dark-color-surface-500)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-500)}@media (max-width:768px){.dark .ant-popover-inner{background-color:var(--dark-color-surface-200)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-200)}}.ant-dropdown-menu-dark .ant-dropdown-menu-item:hover,.dark .ant-select-dropdown-menu-item-selected,.dark .ant-calendar-time-picker-select-option-selected{background-color:var(--dark-color-surface-600)}.ant-menu-dark .ant-menu-item:hover,.ant-menu-dark .ant-menu-submenu-title:hover{background-color:var(--dark-color-surface-300)}.dark .ant-menu-item:active,.dark .ant-menu-submenu-title:active{color:#fff;background-color:var(--dark-color-surface-300)}.dark .ant-alert-message{color:rgb(255 255 255 / .85)}.dark .ant-tag{color:var(--dark-color-tag-color);background-color:var(--dark-color-tag-bg);border-color:var(--dark-color-tag-border)}.dark .ant-tag-blue{background-color:var(--dark-color-tag-blue-bg);border-color:var(--dark-color-tag-blue-border);color:var(--dark-color-tag-blue-color)}.dark .ant-tag-red,.dark .ant-alert-error{background-color:var(--dark-color-tag-red-bg);border-color:var(--dark-color-tag-red-border);color:var(--dark-color-tag-red-color)}.dark .ant-tag-orange,.dark .ant-alert-warning{background-color:var(--dark-color-tag-orange-bg);border-color:var(--dark-color-tag-orange-border);color:var(--dark-color-tag-orange-color)}.dark .ant-tag-green{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border));color:var(--dark-color-tag-green-color)}.dark .ant-tag-purple{background-color:var(--dark-color-tag-purple-bg);border-color:var(--dark-color-tag-purple-border);color:var(--dark-color-tag-purple-color)}.dark .ant-modal-content,.dark .ant-modal-header{background-color:var(--dark-color-surface-700)}.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date{color:var(--dark-color-surface-300)}.dark .ant-calendar-selected-day .ant-calendar-date{background-color:var(--color-primary-100)!important;color:#fff}.dark .ant-calendar-date:hover,.dark .ant-calendar-time-picker-select li:hover{background-color:var(--dark-color-surface-600);color:#fff}.dark .ant-calendar-header a:hover,.dark .ant-calendar-header a:hover::before,.dark .ant-calendar-header a:hover::after{border-color:#fff}.dark .ant-calendar-time-picker-select{border-right-color:var(--dark-color-surface-300)}.has-warning .ant-select-selection,.has-warning .ant-select-selection:hover,.has-warning .ant-input,.has-warning .ant-input:hover{background-color:#ffeee1;border-color:#fec093}.has-warning .ant-input::placeholder{color:#f37b24}.has-warning .ant-input:not([disabled]):hover{border-color:#fec093}.dark .has-warning .ant-select-selection,.dark .has-warning .ant-select-selection:hover,.dark .has-warning .ant-input,.dark .has-warning .ant-input:hover{border-color:#784e1d;background:#312313}.dark .has-warning .ant-input::placeholder{color:rgb(255 160 49 / 70%)}.dark .has-warning .anticon{color:#ffa031}.dark .has-success .anticon{color:var(--color-primary-100);animation-name:diffZoomIn1!important}.dark .anticon-close-circle{color:#e04141}.dark .ant-spin-nested-loading>div>.ant-spin .ant-spin-text{text-shadow:0 1px 2px #0007}.dark .ant-spin{color:#fff}.dark .ant-spin-dot-item{background-color:#fff}.ant-checkbox-wrapper,.ant-input-group-addon,.ant-tabs-tab,.ant-input::placeholder,.ant-collapse-header,.ant-menu,.ant-radio-button-wrapper{-webkit-user-select:none;user-select:none}.ant-calendar-date,.ant-calendar-year-panel-year,.ant-calendar-decade-panel-decade,.ant-calendar-month-panel-month{border-radius:4px}.ant-checkbox-inner,.ant-checkbox-checked:after,.ant-table-row-expand-icon{border-radius:6px}.ant-calendar-date:hover{background-color:#e8f4f2}.ant-calendar-date:active{background-color:#e8f4f2;color:rgb(0 0 0 / .65)}.ant-calendar-today .ant-calendar-date{color:var(--color-primary-100);font-weight:400;border-color:var(--color-primary-100)}.dark .ant-calendar-today .ant-calendar-date{color:#fff;border-color:var(--color-primary-100)}.ant-calendar-selected-day .ant-calendar-date{background:var(--color-primary-100);color:#fff}li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(0 0 0 / .25)}.dark li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(255 255 255 / .3)}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:rgb(0 0 0 / .87)}.dark.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:#fff}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected .ant-select-selected-icon,.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected:hover .ant-select-selected-icon{color:var(--color-primary-100)}.ant-select-selection:hover,.ant-input-number-focused,.ant-input-number:hover{background-color:#e8f4f2}.dark .ant-input-number-handler:active{background-color:var(--color-primary-100)}.dark .ant-input-number-handler:hover .ant-input-number-handler-down-inner,.dark .ant-input-number-handler:hover .ant-input-number-handler-up-inner{color:#fff}.dark .ant-input-number-handler-down{border-top:1px solid rgb(217 217 217 / .3)}.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-century-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-decade-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-month-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-year-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-century-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-decade-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-month-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-year-select{color:rgb(255 255 255 / .85)}.dark .ant-calendar-year-panel-header{border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel-last-decade-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-next-decade-cell .ant-calendar-year-panel-year{color:rgb(255 255 255 / .35)}.dark .ant-divider:not(.ant-divider-with-text-center,.ant-divider-with-text-left,.ant-divider-with-text-right),.ant-dropdown-menu-dark,.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-200)}.dark .ant-calendar-header a:hover{color:#fff}.dark .ant-calendar-month-panel-header{background-color:var(--dark-color-background);border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel,.dark .ant-calendar table{background-color:var(--dark-color-background)}.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade:hover{color:#fff;background-color:var(--color-primary-100)!important}.dark .ant-calendar-last-month-cell .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date:hover,.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-next-month-btn-day .ant-calendar-date:hover{color:rgb(255 255 255 / 25%);background:#fff0;border-color:#fff0}.dark .ant-calendar-today .ant-calendar-date:hover{color:#fff;border-color:var(--color-primary-100);background-color:var(--color-primary-100)}.dark .ant-calendar-decade-panel-last-century-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-next-century-cell .ant-calendar-decade-panel-decade{color:rgb(255 255 255 / 25%)}.dark .ant-calendar-decade-panel-header{border-bottom:1px solid var(--dark-color-surface-200);background-color:var(--dark-color-background)}.dark .ant-checkbox-inner{background-color:rgb(0 135 113 / .3);border-color:rgb(0 135 113 / .3)}.dark .ant-checkbox-checked .ant-checkbox-inner{background-color:var(--color-primary-100);border-color:var(--color-primary-100)}.dark .ant-calendar-input{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-calendar-input::placeholder{color:rgb(255 255 255 / .25)}.ant-input-group.ant-input-group-compact-addon:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact-wrap:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact>.ant-input:not(:first-child):not(:last-child),.ant-input-number-handler,.ant-input-number-handler-wrap{border-radius:0}.ant-input-number{overflow:clip}.ant-modal-body,.ant-collapse-content>.ant-collapse-content-box{overflow-x:auto}.ant-modal-body{overflow-y:hidden}.ant-calendar-year-panel-year:hover,.ant-calendar-decade-panel-decade:hover,.ant-calendar-month-panel-month:hover,.ant-dropdown-menu-item:hover,.ant-dropdown-menu-submenu-title:hover,.ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled),.ant-table-tbody>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td{background-color:#e8f4f2}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.ant-select-dropdown,.ant-popover-inner{overflow-x:hidden}.ant-popover-inner-content{max-height:450px;overflow-y:auto}@media (max-height:900px){.ant-popover-inner-content{max-height:400px}}@media (max-height:768px){.ant-popover-inner-content{max-height:300px}}@media (max-width:768px){.ant-popover-inner-content{max-height:300px}}.qr-modal{display:flex;align-items:flex-end;gap:10px;flex-direction:column;flex-wrap:wrap;row-gap:24px}.qr-box{width:220px}.qr-cv{width:100%;height:100%}.dark .qr-cv{background-color:#fff;padding:1px;border-radius:0.25rem}.qr-bg{background-color:#fff;display:flex;justify-content:center;align-content:center;padding:.8rem;border-radius:1rem;border:solid 1px #e8e8e8;height:220px;width:220px;transition:all 0.1s}.qr-bg:hover{border-color:#76ccb4;background-color:#eafff9}.qr-bg:hover:active{border-color:#76ccb4;background-color:rgb(197 241 228 / 70%)}.dark .qr-bg{background-color:var(--dark-color-surface-700);border-color:var(--dark-color-surface-300)}.dark .qr-bg:hover{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border))}.dark .qr-bg:hover:active{background-color:#17322e}@property --tr-rotate{syntax:'';initial-value:45deg;inherits:false}.qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#76ccb4,transparent,#d5bed2);display:flex;justify-content:center;align-content:center;padding:1px;border-radius:1rem;height:220px;width:220px}.dark .qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#195141,transparent,#5a2969)}.qr-bg-sub:hover{animation:tr-rotate-gradient 3.5s linear infinite}@keyframes tr-rotate-gradient{from{--tr-rotate:45deg}to{--tr-rotate:405deg}}.qr-bg-sub-inner{background-color:#fff;padding:.8rem;border-radius:1rem;transition:all 0.1s}.qr-bg-sub-inner:hover{background-color:rgb(255 255 255 / 60%);backdrop-filter:blur(25px)}.qr-bg-sub-inner:hover:active{background-color:rgb(255 255 255 / 30%)}.dark .qr-bg-sub-inner{background-color:rgb(var(--dark-color-surface-700-rgb))}.dark .qr-bg-sub-inner:hover{background-color:rgba(var(--dark-color-surface-700-rgb),.5);backdrop-filter:blur(25px)}.dark .qr-bg-sub-inner:hover:active{background-color:rgba(var(--dark-color-surface-700-rgb),.2)}.qr-tag{text-align:center;margin-bottom:10px;width:100%;overflow:hidden;margin-inline:0}@media (min-width:769px){.qr-modal{flex-direction:row;max-width:680px}}.tr-marquee{justify-content:flex-start}.tr-marquee span{padding-right:25%;white-space:nowrap;transform-origin:center}@keyframes move-ltr{0%{transform:translateX(0)}100%{transform:translateX(-100%)}}.ant-input-group-addon:not(:first-child):not(:last-child){border-radius:0rem 1rem 1rem 0rem}b,strong{font-weight:500}.ant-collapse>.ant-collapse-item>.ant-collapse-header{padding:10px 16px 10px 40px}.dark .ant-message-notice-content{background-color:var(--dark-color-surface-200);border:1px solid var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.ant-btn-danger{background-color:var(--dark-color-btn-danger);border-color:var(--dark-color-btn-danger-border)}.ant-btn-danger:focus,.ant-btn-danger:hover{background-color:var(--dark-color-btn-danger-hover);border-color:var(--dark-color-btn-danger-hover)}.dark .ant-alert-close-icon .anticon-close:hover{color:#fff}.ant-empty-small{margin:4px 0;background-color:transparent!important}.ant-empty-small .ant-empty-image{height:20px}.ant-menu-theme-switch,.ant-menu-theme-switch:hover{background-color:transparent!important;cursor:default!important}.dark .ant-tooltip-inner,.dark .ant-tooltip-arrow:before{background-color:var(--dark-color-tooltip)}.ant-select-sm .ant-select-selection__rendered{margin-left:10px}.ant-collapse{-moz-animation:collfade 0.3s ease;-webkit-animation:0.3s collfade 0.3s ease;animation:collfade 0.3s ease}@-webkit-keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}@keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}.ant-table-tbody>tr>td{border-color:#f0f0f0}.ant-table-row-expand-icon{vertical-align:middle;margin-inline-end:8px;position:relative;transform:scale(.9411764705882353)}.ant-table-row-collapsed::before{transform:rotate(-180deg);top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-collapsed::after{transform:rotate(0deg);top:3px;bottom:3px;inset-inline-start:7px;width:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::before{top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::after{top:3px;bottom:3px;inset-inline-start:7px;width:1px;transform:rotate(90deg);position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-menu-theme-switch.ant-menu-item .ant-switch:not(.ant-switch-disabled):active:after,.ant-switch:not(.ant-switch-disabled):active:before{width:16px}.dark .ant-select-disabled .ant-select-selection{background:var(--dark-color-surface-100);border-color:var(--dark-color-surface-200);color:rgb(255 255 255 / .25)}.dark .ant-select-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .1)}.dark .ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up-disabled .anticon,.dark .ant-input-number-handler-down:hover.ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up:hover.ant-input-number-handler-up-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down:active.ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up:active.ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .2)}.ant-menu-dark .ant-menu-inline.ant-menu-sub{background:var(--dark-color-surface-100);box-shadow:none}.dark .ant-layout-sider-trigger{background:var(--dark-color-surface-100);color:rgb(255 255 255 / 65%)}.ant-layout-sider{overflow:auto}.dark .ant-back-top-content{background-color:var(--dark-color-back-top)}.dark .ant-back-top-content:hover{background-color:var(--dark-color-back-top-hover)}.ant-calendar-time .ant-calendar-footer .ant-calendar-time-picker-btn{text-transform:capitalize}.ant-calendar{border-color:#fff0;border-width:0}.ant-calendar-time-picker-select li:focus,li.ant-calendar-time-picker-select-option-selected{color:rgb(0 0 0 / .65);font-weight:400;background-color:#e8f4f2}.dark li.ant-calendar-time-picker-select-option-selected{color:var(--dark-color-text-primary);font-weight:400}.dark .ant-calendar-time-picker-select li:focus{color:#fff;font-weight:400;background-color:var(--color-primary-100)}.ant-calendar-time-picker-select li:hover{background:#f5f5f5}.ant-calendar-date{transition:background .3s ease,color .3s ease}li.ant-calendar-time-picker-select-option-selected{margin-block:2px}.ant-calendar-time-picker-select{padding:4px}.ant-calendar-time-picker-select li{height:28px;line-height:28px;border-radius:4px}@media (min-width:769px){.index-page .ant-layout-content{margin:24px 16px}}.index-page .ant-card-dark h2{color:var(--dark-color-text-primary)}.index-page~div .ant-backup-list-item{gap:10px}.index-page~div .ant-version-list-item{--padding:12px;padding:var(--padding)!important;gap:var(--padding)}.index-page.dark~div .ant-version-list-item svg{color:var(--dark-color-text-primary)}.index-page.dark~div .ant-backup-list-item svg,.index-page.dark .ant-badge-status-text,.index-page.dark .ant-card-extra{color:var(--dark-color-text-primary)}.index-page.dark .ant-card-actions>li{color:rgb(255 255 255 / .55)}.index-page.dark~div .ant-radio-inner{background-color:var(--dark-color-surface-100);border-color:var(--dark-color-surface-600)}.index-page.dark~div .ant-radio-checked .ant-radio-inner{border-color:var(--color-primary-100)}.index-page.dark~div .ant-backup-list,.index-page.dark~div .ant-version-list,.index-page.dark .ant-card-actions,.index-page.dark .ant-card-actions>li:not(:last-child){border-color:var(--dark-color-stroke)}.index-page .ant-card-actions{background:#fff0}.index-page .ip-hidden{-webkit-user-select:none;-moz-user-select:none;user-select:none;filter:blur(10px)}.index-page .xray-running-animation .ant-badge-status-dot,.index-page .xray-processing-animation .ant-badge-status-dot{animation:runningAnimation 1.2s linear infinite}.index-page .xray-running-animation .ant-badge-status-processing:after{border-color:var(--color-primary-100)}.index-page .xray-stop-animation .ant-badge-status-processing:after{border-color:#fa8c16}.index-page .xray-error-animation .ant-badge-status-processing:after{border-color:#f5222d}@keyframes runningAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.index-page .card-placeholder{text-align:center;padding:30px 0;margin-top:10px;background:#fff0;border:none}.index-page~div .log-container{height:auto;max-height:500px;overflow:auto;margin-top:.5rem}#app.login-app *{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app.login-app h1{text-align:center;height:110px}#app.login-app .ant-form-item-children .ant-btn,#app.login-app .ant-input{height:50px;border-radius:30px}#app.login-app .ant-input-group-addon{border-radius:0 30px 30px 0;width:50px;font-size:18px}#app.login-app .ant-input-affix-wrapper .ant-input-prefix{left:23px}#app.login-app .ant-input-affix-wrapper .ant-input:not(:first-child){padding-left:50px}#app.login-app .centered{display:flex;text-align:center;align-items:center;justify-content:center;width:100%}#app.login-app .title{font-size:2rem;margin-block-end:2rem}#app.login-app .title b{font-weight:bold!important}#app.login-app{overflow:hidden}#app.login-app #login{animation:charge 0.5s both;background-color:#fff;border-radius:2rem;padding:4rem 3rem;transition:all 0.3s;user-select:none;-webkit-user-select:none;-moz-user-select:none}#app.login-app #login:hover{box-shadow:0 2px 8px rgb(0 0 0 / .09)}@keyframes charge{from{transform:translateY(5rem);opacity:0}to{transform:translateY(0);opacity:1}}#app.login-app .under{background-color:#c7ebe2;z-index:0}#app.login-app.dark .under{background-color:var(--dark-color-login-wave)}#app.login-app.dark #login{background-color:var(--dark-color-surface-100)}#app.login-app.dark h1{color:#fff}#app.login-app .ant-btn-primary-login{width:100%}#app.login-app .ant-btn-primary-login:focus,#app.login-app .ant-btn-primary-login:hover{color:#fff;background-color:#065;border-color:#065;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move ease-in-out 5s infinite;background-position-x:-500px;width:95%;animation-delay:-0.5s;box-shadow:0 2px 0 rgb(0 0 0 / .045)}#app.login-app .ant-btn-primary-login.active,#app.login-app .ant-btn-primary-login:active{color:#fff;background-color:#065;border-color:#065}@keyframes ma-bg-move{0%{background-position:-500px 0}50%{background-position:1000px 0}100%{background-position:1000px 0}}#app.login-app .wave-btn-bg{position:relative;border-radius:25px;width:100%;transition:all 0.3s cubic-bezier(.645,.045,.355,1)}#app.login-app.dark .wave-btn-bg{color:#fff;position:relative;background-color:#0a7557;border:2px double #fff0;background-origin:border-box;background-clip:padding-box,border-box;background-size:300%;width:100%;z-index:1}#app.login-app.dark .wave-btn-bg:hover{animation:wave-btn-tara 4s ease infinite}#app.login-app.dark .wave-btn-bg-cl{background-image:linear-gradient(#fff0,#fff0),radial-gradient(circle at left top,#006655,#009980,#006655)!important;border-radius:3em}#app.login-app.dark .wave-btn-bg-cl:hover{width:95%}#app.login-app.dark .wave-btn-bg-cl:before{position:absolute;content:"";top:-5px;left:-5px;bottom:-5px;right:-5px;z-index:-1;background:inherit;background-size:inherit;border-radius:4em;opacity:0;transition:0.5s}#app.login-app.dark .wave-btn-bg-cl:hover::before{opacity:1;filter:blur(20px);animation:wave-btn-tara 8s linear infinite}@keyframes wave-btn-tara{to{background-position:300%}}#app.login-app.dark .ant-btn-primary-login{font-size:14px;color:#fff;text-align:center;background-image:linear-gradient(rgb(13 14 33 / .45),rgb(13 14 33 / .35));border-radius:2rem;border:none;outline:none;background-color:#fff0;height:46px;position:relative;white-space:nowrap;cursor:pointer;touch-action:manipulation;padding:0 15px;width:100%;animation:none;background-position-x:0;box-shadow:none}#app.login-app .waves-header{position:fixed;width:100%;text-align:center;background-color:#dbf5ed;color:#fff;z-index:-1}#app.login-app.dark .waves-header{background-color:var(--dark-color-login-background)}#app.login-app .waves-inner-header{height:50vh;width:100%;margin:0;padding:0}#app.login-app .waves{position:relative;width:100%;height:15vh;margin-bottom:-8px;min-height:100px;max-height:150px}#app.login-app .parallax>use{animation:move-forever 25s cubic-bezier(.55,.5,.45,.5) infinite}#app.login-app.dark .parallax>use{fill:var(--dark-color-login-wave)}#app.login-app .parallax>use:nth-child(1){animation-delay:-2s;animation-duration:4s;opacity:.2}#app.login-app .parallax>use:nth-child(2){animation-delay:-3s;animation-duration:7s;opacity:.4}#app.login-app .parallax>use:nth-child(3){animation-delay:-4s;animation-duration:10s;opacity:.6}#app.login-app .parallax>use:nth-child(4){animation-delay:-5s;animation-duration:13s}@keyframes move-forever{0%{transform:translate3d(-90px,0,0)}100%{transform:translate3d(85px,0,0)}}@media (max-width:768px){#app.login-app .waves{height:40px;min-height:40px}}#app.login-app .words-wrapper{width:100%;display:inline-block;position:relative;text-align:center}#app.login-app .words-wrapper b{width:100%;display:inline-block;position:absolute;left:0;top:0}#app.login-app .words-wrapper b.is-visible{position:relative}#app.login-app .headline.zoom .words-wrapper{-webkit-perspective:300px;-moz-perspective:300px;perspective:300px}#app.login-app .headline{display:flex;justify-content:center;align-items:center}#app.login-app .headline.zoom b{opacity:0}#app.login-app .headline.zoom b.is-visible{opacity:1;-webkit-animation:zoom-in 0.8s;-moz-animation:zoom-in 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-in 0.8s}#app.login-app .headline.zoom b.is-hidden{-webkit-animation:zoom-out 0.8s;-moz-animation:zoom-out 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-out 0.4s}@-webkit-keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0)}}@-moz-keyframes zoom-in{0%{opacity:0;-moz-transform:translateZ(100px)}100%{opacity:1;-moz-transform:translateZ(0)}}@keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px);-moz-transform:translateZ(100px);-ms-transform:translateZ(100px);-o-transform:translateZ(100px);transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}}@-webkit-keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px)}}@-moz-keyframes zoom-out{0%{opacity:1;-moz-transform:translateZ(0)}100%{opacity:0;-moz-transform:translateZ(-100px)}}@keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px);-moz-transform:translateZ(-100px);-ms-transform:translateZ(-100px);-o-transform:translateZ(-100px);transform:translateZ(-100px)}}#app.login-app .setting-section{position:absolute;top:0;right:0;padding:22px}#app.login-app .ant-space-item .ant-switch{margin:2px 0 4px}#app.login-app .ant-layout-content{transition:none}.inbounds-page .ant-table:not(.ant-table-expanded-row .ant-table){outline:1px solid #f0f0f0;outline-offset:-1px;border-radius:1rem;overflow-x:hidden}.inbounds-page.dark .ant-table:not(.ant-table-expanded-row .ant-table){outline-color:var(--dark-color-table-ring)}.inbounds-page .ant-table .ant-table-content .ant-table-scroll .ant-table-body{overflow-y:hidden}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 22px!important}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper .ant-table{border-bottom-left-radius:1rem;border-bottom-right-radius:1rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child tr:last-child td{border-bottom-color:#fff0}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:first-child{border-bottom-left-radius:6px}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:last-child{border-bottom-right-radius:6px}@media (min-width:769px){.inbounds-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.inbounds-page .ant-card-body{padding:.5rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 2px!important}}.inbounds-page.dark~div .ant-switch-small:not(.ant-switch-checked){background-color:var(--dark-color-surface-100)}.inbounds-page .ant-custom-popover-title{display:flex;align-items:center;gap:10px;margin:5px 0}.inbounds-page .ant-col-sm-24{margin:.5rem -2rem .5rem 2rem}.inbounds-page tr.hideExpandIcon .ant-table-row-expand-icon{display:none}.inbounds-page .infinite-tag,.inbounds-page~div .infinite-tag{padding:0 5px;border-radius:2rem;min-width:50px;min-height:22px}.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#F2EAF1;border:#D5BED2 solid 1px}.inbounds-page.dark .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#7a316f!important;border:#7a316f solid 1px}.inbounds-page~div .ant-collapse{margin:5px 0}.inbounds-page .info-large-tag,.inbounds-page~div .info-large-tag{max-width:200px;overflow:hidden}.inbounds-page .client-comment{font-size:12px;opacity:.75;cursor:help}.inbounds-page .client-email{font-weight:500}.inbounds-page .client-popup-item{display:flex;align-items:center;gap:5px}.inbounds-page .online-animation .ant-badge-status-dot{animation:onlineAnimation 1.2s linear infinite}@keyframes onlineAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.inbounds-page .tr-table-box{display:flex;gap:4px;justify-content:center;align-items:center}.inbounds-page .tr-table-rt{flex-basis:70px;min-width:70px;text-align:end}.inbounds-page .tr-table-lt{flex-basis:70px;min-width:70px;text-align:start}.inbounds-page .tr-table-bar{flex-basis:160px;min-width:60px}.inbounds-page .tr-infinity-ch{font-size:14pt;max-height:24px;display:inline-flex;align-items:center}.inbounds-page .ant-table-expanded-row .ant-table .ant-table-body{overflow-x:hidden}.inbounds-page .ant-table-expanded-row .ant-table-tbody>tr>td{padding:10px 2px}.inbounds-page .ant-table-expanded-row .ant-table-thead>tr>th{padding:12px 2px}.idx-cpu-history-svg{display:block;overflow:unset!important}.dark .idx-cpu-history-svg .cpu-grid-line{stroke:rgb(255 255 255 / .08)}.dark .idx-cpu-history-svg .cpu-grid-h-line{stroke:rgb(255 255 255 / .25)}.dark .idx-cpu-history-svg .cpu-grid-y-text,.dark .idx-cpu-history-svg .cpu-grid-x-text{fill:rgb(200 200 200 / .8)}.idx-cpu-history-svg .cpu-grid-text{stroke-width:3;paint-order:stroke;stroke:rgb(0 0 0 / .05)}.dark .idx-cpu-history-svg .cpu-grid-text{fill:#fff;stroke:rgb(0 0 0 / .35)}.inbounds-page~div #inbound-modal form textarea.ant-input{margin:4px 0}@media (min-width:769px){.settings-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.settings-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}}.settings-page .ant-tabs-bar{margin:0}.settings-page .ant-list-item{display:block}.settings-page .alert-msg{color:#c27512;font-weight:400;font-size:16px;padding:.5rem 1rem;text-align:center;background:rgb(255 145 0 / 15%);margin:1.5rem 2.5rem 0rem;border-radius:.5rem;transition:all 0.5s;animation:settings-page-signal 3s cubic-bezier(.18,.89,.32,1.28) infinite}.settings-page .alert-msg:hover{cursor:default;transition-duration:.3s;animation:settings-page-signal 0.9s ease infinite}@keyframes settings-page-signal{0%{box-shadow:0 0 0 0 rgb(194 118 18 / .5)}50%{box-shadow:0 0 0 6px #fff0}100%{box-shadow:0 0 0 6px #fff0}}.settings-page .alert-msg>i{color:inherit;font-size:24px}.settings-page.dark .ant-input-password-icon{color:var(--dark-color-text-primary)}.settings-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}@media (min-width:769px){.xray-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.xray-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}.xray-page .ant-table-thead>tr>th,.xray-page .ant-table-tbody>tr>td{padding:10px 0}}.xray-page .ant-tabs-bar{margin:0}.xray-page .ant-list-item{display:block}.xray-page .ant-list-item>li{padding:10px 20px!important}.xray-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}#app.login-app #login input.ant-input:-webkit-autofill{-webkit-box-shadow:0 0 0 100px #f8f8f8 inset;box-shadow:0 0 0 100px #f8f8f8 inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s;background-clip:text}#app.login-app #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app #login input.ant-input:-webkit-autofill:hover,#app.login-app #login input.ant-input:-webkit-autofill:focus{-webkit-box-shadow:0 0 0 100px #e8f4f2 inset;box-shadow:0 0 0 100px #e8f4f2 inset}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill{-webkit-text-fill-color:var(--dark-color-text-primary);caret-color:var(--dark-color-text-primary);-webkit-box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill:hover,#app.login-app.dark #login input.ant-input:-webkit-autofill:focus{border-color:var(--dark-color-surface-300)}.dark .ant-descriptions-bordered .ant-descriptions-item-label{background-color:var(--dark-color-background)}.dark .ant-descriptions-bordered .ant-descriptions-view,.dark .ant-descriptions-bordered .ant-descriptions-row,.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-list-bordered{border-color:var(--dark-color-surface-400)}.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-descriptions-bordered .ant-descriptions-item-content{color:var(--dark-color-text-primary)}.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-200)}.dark .ant-dropdown-menu .ant-dropdown-menu-item{color:hsl(0 0% 100% / .65)}.dark .ant-dropdown-menu .ant-dropdown-menu-item:hover{background-color:var(--dark-color-surface-600)}.subscription-page .ant-list.ant-list-split.ant-list-bordered{overflow:hidden}.subscription-page .ant-list.ant-list-split.ant-list-bordered .ant-list-item{overflow-x:auto}.subscription-page .ant-btn.ant-btn-primary.ant-btn-lg.ant-dropdown-trigger{border-radius:4rem;padding:0 20px}.subscription-page .subscription-card{margin:2rem 0}.mb-10{margin-bottom:10px}.mb-12{margin-bottom:12px}.mt-5{margin-top:5px}.mr-8{margin-right:8px}.ml-10{margin-left:10px}.mr-05{margin-right:.5rem}.fs-1rem{font-size:1rem}.w-100{width:100%}.w-70{width:70px}.w-95{width:95px}.text-center{text-align:center}.cursor-pointer{cursor:pointer}.float-right{float:right}.va-middle{vertical-align:middle}.d-flex{display:flex}.justify-end{justify-content:flex-end}.max-w-400{max-width:400px;display:inline-block}.ant-space.jc-center{justify-content:center}.min-h-0{min-height:0}.min-h-100vh{min-height:100vh}.h-100{height:100%}.h-50px{height:50px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-auto{overflow-x:auto}.mt-1rem{margin-top:1rem}.my-3rem{margin-top:3rem;margin-bottom:3rem} \ No newline at end of file +:root{--color-primary-100:#008771;--dark-color-background:#0a1222;--dark-color-surface-100:#151f31;--dark-color-surface-200:#222d42;--dark-color-surface-300:#2c3950;--dark-color-surface-400:rgba(65, 85, 119, 0.5);--dark-color-surface-500:#2c3950;--dark-color-surface-600:#313f5a;--dark-color-surface-700:#111929;--dark-color-surface-700-rgb:17, 25, 41;--dark-color-table-hover:rgba(44, 57, 80, 0.2);--dark-color-text-primary:rgba(255, 255, 255, 0.75);--dark-color-stroke:#2c3950;--dark-color-btn-danger:#cd3838;--dark-color-btn-danger-border:transparent;--dark-color-btn-danger-hover:#e94b4b;--dark-color-tag-bg:rgba(255, 255, 255, 0.05);--dark-color-tag-border:rgba(255, 255, 255, 0.15);--dark-color-tag-color:rgba(255, 255, 255, 0.75);--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:25, 81, 65;--dark-color-tag-green-color:#3ad3ba;--dark-color-tag-purple-bg:#201425;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d988cd;--dark-color-tag-red-bg:#291515;--dark-color-tag-red-border:#5c2626;--dark-color-tag-red-color:#e04141;--dark-color-tag-orange-bg:#312313;--dark-color-tag-orange-border:#593914;--dark-color-tag-orange-color:#ffa031;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#1348ab;--dark-color-tag-blue-color:#529fff;--dark-color-codemirror-line-hover:rgba(0, 135, 113, 0.2);--dark-color-codemirror-line-selection:rgba(0, 135, 113, 0.3);--dark-color-login-background:var(--dark-color-background);--dark-color-login-wave:var(--dark-color-surface-200);--dark-color-tooltip:rgba(61, 76, 104, 0.9);--dark-color-back-top:rgba(61, 76, 104, 0.9);--dark-color-back-top-hover:rgba(61, 76, 104, 1);--dark-color-scrollbar:#313f5a;--dark-color-scrollbar-webkit:#7484a0;--dark-color-scrollbar-webkit-hover:#90a4c7;--dark-color-table-ring:rgb(38 52 77);--dark-color-spin-container:#151f31}html[data-theme-animations='off']{.ant-menu,.ant-layout-sider,.ant-card,.ant-tag,.ant-progress-circle>*,.ant-input,.ant-table-row-expand-icon,.ant-switch,.ant-table-thead>tr>th,.ant-select-selection,.ant-btn,.ant-input-number,.ant-input-group-addon,.ant-checkbox-inner,.ant-progress-bg,.ant-progress-success-bg,.ant-radio-button-wrapper:not(:first-child):before,.ant-radio-button-wrapper,#login,.cm-s-xq.CodeMirror{transition:border 0s,background 0s!important}.ant-menu.ant-menu-inline .ant-menu-item:not(.ant-menu-sub .ant-menu-item),.ant-layout-sider-trigger,.ant-alert-close-icon .anticon-close,.ant-tabs-nav .ant-tabs-tab,.ant-input-number-input,.ant-collapse>.ant-collapse-item>.ant-collapse-header,.Line-Hover,.ant-menu-theme-switch,.ant-menu-submenu-title{transition:color 0s!important}.wave-btn-bg{transition:width 0s!important}.index-page .ant-row .ant-col .ant-card{animation:none!important;opacity:1!important}}html[data-theme='ultra-dark']{--dark-color-background:#21242a;--dark-color-surface-100:#0c0e12;--dark-color-surface-200:#222327;--dark-color-surface-300:#32353b;--dark-color-surface-400:rgba(255, 255, 255, 0.1);--dark-color-surface-500:#3b404b;--dark-color-surface-600:#505663;--dark-color-surface-700:#101113;--dark-color-surface-700-rgb:16, 17, 19;--dark-color-table-hover:rgba(89, 89, 89, 0.15);--dark-color-text-primary:rgb(255 255 255 / 85%);--dark-color-stroke:#202025;--dark-color-tag-green-bg:17, 36, 33;--dark-color-tag-green-border:29, 95, 77;--dark-color-tag-green-color:#59cbac;--dark-color-tag-purple-bg:#241121;--dark-color-tag-purple-border:#5a2969;--dark-color-tag-purple-color:#d686ca;--dark-color-tag-red-bg:#2a1215;--dark-color-tag-red-border:#58181c;--dark-color-tag-red-color:#e84749;--dark-color-tag-orange-bg:#2b1d11;--dark-color-tag-orange-border:#593815;--dark-color-tag-orange-color:#e89a3c;--dark-color-tag-blue-bg:#111a2c;--dark-color-tag-blue-border:#0f367e;--dark-color-tag-blue-color:#3c89e8;--dark-color-codemirror-line-hover:rgba(82, 84, 94, 0.2);--dark-color-codemirror-line-selection:rgba(82, 84, 94, 0.3);--dark-color-login-background:#0a2227;--dark-color-login-wave:#0f2d32;--dark-color-tooltip:rgba(88, 93, 100, 0.9);--dark-color-back-top:rgba(88, 93, 100, 0.9);--dark-color-back-top-hover:rgba(88, 93, 100, 1);--dark-color-scrollbar:rgb(107,107,107);--dark-color-scrollbar-webkit:#9f9f9f;--dark-color-scrollbar-webkit-hover:#d1d1d1;--dark-color-table-ring:rgb(37 39 42);--dark-color-spin-container:#1d1d1d;.ant-dropdown-menu-dark,.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-500)}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.dark .waves-header{background-color:#0a2227}.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-600)}}html,body{height:100vh;width:100vw;margin:0;padding:0;overflow:hidden}body{color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;background-color:#fff;font-feature-settings:"tnum"}html{--antd-wave-shadow-color:var(--color-primary-100);line-height:1.15;text-size-adjust:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-moz-tap-highlight-color:#fff0;-webkit-tap-highlight-color:#fff0}html[data-glass-morphism="true"]{--color-primary-100:#008771;--antd-wave-shadow-color:#008771;--c-glass:#bbbbbc;--c-light:#fff;--c-dark:#000;--glass-reflex-dark:1;--glass-reflex-light:1;--saturation:150%}@supports (scrollbar-width:auto) and (not selector(::-webkit-scrollbar)){:not(.dark){scrollbar-color:#9a9a9a #fff0;scrollbar-width:thin}.dark *{scrollbar-color:var(--dark-color-scrollbar) #fff0;scrollbar-width:thin}}::-webkit-scrollbar{width:10px;height:10px;background-color:#fff0}::-webkit-scrollbar-track{background-color:#fff0;margin-block:.5em}.ant-modal-wrap::-webkit-scrollbar-track{background-color:#fff;margin-block:0}::-webkit-scrollbar-thumb{border-radius:9999px;background-color:#9a9a9a;border:2px solid #fff0;background-clip:content-box}::-webkit-scrollbar-thumb:hover,::-webkit-scrollbar-thumb:active{background-color:#828282}.dark .ant-modal-wrap::-webkit-scrollbar-track{background-color:var(--dark-color-background)}.dark::-webkit-scrollbar-thumb{background-color:var(--dark-color-scrollbar-webkit)}.dark::-webkit-scrollbar-thumb:hover,.dark::-webkit-scrollbar-thumb:active{background-color:var(--dark-color-scrollbar-webkit-hover)}::-moz-selection{color:var(--color-primary-100);background-color:#cfe8e4}::selection{color:var(--color-primary-100);background-color:#cfe8e4}html[data-glass-morphism="true"] ::-moz-selection{color:rgba(0,0,0,.85);background-color:rgba(255,255,255,.3)}html[data-glass-morphism="true"] ::selection{color:rgba(0,0,0,.85);background-color:rgba(255,255,255,.3)}#app{height:100%;position:fixed;top:0;left:0;right:0;bottom:0;margin:0;padding:0;overflow:auto}.ant-layout,.ant-layout *{box-sizing:border-box}.ant-spin-container:after{border-radius:1.5rem}.dark .ant-spin-container:after{background:var(--dark-color-spin-container)}style attribute{text-align:center}.ant-table-thead>tr>th{padding:12px 8px}.ant-table-tbody>tr>td{padding:10px 8px}.ant-table-thead>tr>th{color:rgb(0 0 0 / .85);font-weight:500;text-align:left;border-bottom:1px solid #e8e8e8;transition:background 0.3s ease}.ant-table table{border-radius:1rem}.ant-table-content,.ant-table-body,.ant-table-header,.ant-table-container,.ant-table-scroll,.ant-table-content table,.ant-table-body table,.ant-table-header table{border-radius:1rem}.inbounds-page .ant-table:not(.ant-table-expanded-row .ant-table),.clients-page .ant-table,.hosts-page .ant-table,.nodes-page .ant-table{border-radius:1rem!important}.inbounds-page .ant-table .ant-table-content,.inbounds-page .ant-table .ant-table-body,.inbounds-page .ant-table .ant-table-header,.clients-page .ant-table .ant-table-content,.clients-page .ant-table .ant-table-body,.clients-page .ant-table .ant-table-header,.hosts-page .ant-table .ant-table-content,.hosts-page .ant-table .ant-table-body,.hosts-page .ant-table .ant-table-header,.nodes-page .ant-table .ant-table-content,.nodes-page .ant-table .ant-table-body,.nodes-page .ant-table .ant-table-header{border-radius:1rem!important}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:first-child{border-bottom-left-radius:1rem}.ant-table-bordered .ant-table-tbody:not(.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody)>tr:last-child>td:last-child{border-bottom-right-radius:1rem}.ant-table{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;font-feature-settings:"tnum";position:relative;clear:both}.ant-table .ant-table-body:not(.ant-table-expanded-row .ant-table-body){overflow-x:auto!important}.ant-card-hoverable{cursor:auto;cursor:pointer}.ant-card{box-sizing:border-box;margin:0;padding:0;color:rgb(0 0 0 / .65);font-size:14px;font-variant:tabular-nums;line-height:1.5;list-style:none;position:relative;background-color:#fff;border-radius:2px;transition:all 0.3s}.ant-space{width:100%}.ant-layout-sider-zero-width-trigger{display:none}@media (max-width:768px){.ant-layout-sider{display:none}.ant-card,.ant-alert-error{margin:.5rem}.ant-tabs{margin:.5rem;padding:.5rem}.ant-modal-body{padding:20px}.ant-form-item-label{line-height:1.5;padding:8px 0 0}:not(.dark)::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}.dark::-webkit-scrollbar{width:8px;height:8px;background-color:#fff0}}.ant-layout-content{min-height:auto}.ant-card,.ant-tabs{border-radius:1.5rem}.ant-card-hoverable{cursor:auto}.ant-card+.ant-card{margin-top:20px}.drawer-handle{position:absolute;top:72px;width:41px;height:40px;cursor:pointer;z-index:0;text-align:center;line-height:40px;font-size:16px;display:flex;justify-content:center;align-items:center;background-color:#fff;right:-40px;box-shadow:2px 0 8px rgb(0 0 0 / .15);border-radius:0 4px 4px 0}.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{background-color:#006655!important;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move linear 6.6s infinite;color:#fff;border-radius:.5rem}.ant-layout-sider-collapsed .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{border-radius:0}.ant-menu-item-active,.ant-menu-item:hover,.ant-menu-submenu-title:hover,.ant-menu-item:active,.ant-menu-submenu-title:active{color:var(--color-primary-100);background-color:#e8f4f2}.ant-menu-inline .ant-menu-item,.ant-menu-inline .ant-menu-submenu-title{border-radius:.5rem}.ant-menu-inline .ant-menu-item:after,.ant-menu{border-right-width:0}.ant-layout-sider:not(.ant-layout-sider-collapsed){width:auto!important;min-width:200px!important;max-width:none!important}.ant-menu-item,.ant-menu-submenu-title{white-space:nowrap!important;word-break:keep-all!important;overflow:visible!important}.ant-menu-item span,.ant-menu-submenu-title span{display:inline-block!important;max-width:none!important;vertical-align:middle!important;white-space:nowrap!important}.ant-layout-sider-children,.ant-pagination ul{padding:.5rem}.ant-layout-sider-collapsed .ant-layout-sider-children{padding:.5rem 0}.ant-dropdown-menu,.ant-select-dropdown-menu{padding:.5rem}.ant-dropdown-menu-item,.ant-dropdown-menu-item:hover,.ant-select-dropdown-menu-item,.ant-select-dropdown-menu-item:hover,.ant-select-selection--multiple .ant-select-selection__choice{border-radius:.5rem}.ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item,.ant-select-dropdown--single .ant-select-dropdown-menu .ant-select-dropdown-menu-item-selected{margin-block:2px}@media (min-width:769px){.drawer-handle{display:none}.ant-tabs{padding:2rem}}.fade-in-enter,.fade-in-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active,.fade-in-linear-enter,.fade-in-linear-leave,.fade-in-linear-leave-active{opacity:0}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-linear-enter-active,.fade-in-linear-leave-active{-webkit-transition:opacity 0.2s linear;transition:opacity 0.2s linear}.fade-in-enter-active,.fade-in-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter-active,.zoom-in-center-leave-active{-webkit-transition:all 0.3s cubic-bezier(.55,0,.1,1);transition:all 0.3s cubic-bezier(.55,0,.1,1)}.zoom-in-center-enter,.zoom-in-center-leave-active{opacity:0;-webkit-transform:scaleX(0);transform:scaleX(0)}.zoom-in-top-enter-active,.zoom-in-top-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center top;transform-origin:center top}.zoom-in-top-enter,.zoom-in-top-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-bottom-enter-active,.zoom-in-bottom-leave-active{opacity:1;-webkit-transform:scaleY(1);transform:scaleY(1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:center bottom;transform-origin:center bottom}.zoom-in-bottom-enter,.zoom-in-bottom-leave-active{opacity:0;-webkit-transform:scaleY(0);transform:scaleY(0)}.zoom-in-left-enter-active,.zoom-in-left-leave-active{opacity:1;-webkit-transform:scale(1,1);transform:scale(1,1);-webkit-transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1);transition:transform 0.3s cubic-bezier(.23,1,.32,1),opacity 0.3s cubic-bezier(.23,1,.32,1),-webkit-transform 0.3s cubic-bezier(.23,1,.32,1);-webkit-transform-origin:top left;transform-origin:top left}.zoom-in-left-enter,.zoom-in-left-leave-active{opacity:0;-webkit-transform:scale(.45,.45);transform:scale(.45,.45)}.list-enter-active,.list-leave-active{-webkit-transition:all 0.3s;transition:all 0.3s}.list-enter,.list-leave-active{opacity:0;-webkit-transform:translateY(-30px);transform:translateY(-30px)}.ant-tooltip-inner{min-height:0;padding-inline:1rem}.ant-list-item-meta-title{font-size:14px}.ant-progress-inner{background-color:#ebeef5}.deactive-client .ant-collapse-header{color:#ffffff!important;background-color:#ff7f7f}.ant-table-expand-icon-th,.ant-table-row-expand-icon-cell{width:30px;min-width:30px}.ant-tabs{background-color:#fff}.ant-form-item{margin-bottom:0}.ant-setting-textarea{margin-top:1.5rem}.client-table-header{background-color:#f0f2f5}.client-table-odd-row{background-color:#fafafa}.ant-table-pagination.ant-pagination{float:left}.ant-tag{margin-right:0;margin-inline:2px;display:inline-flex;align-items:center;justify-content:space-evenly}.ant-tag:not(.qr-tag){column-gap:4px}#inbound-info-modal .ant-tag{margin-block:2px}.tr-info-table{display:inline-table;margin-block:10px;width:100%}#inbound-info-modal .tr-info-table .ant-tag{margin-block:0;margin-inline:0}.tr-info-row{display:flex;flex-direction:column;row-gap:2px;margin-block:10px}.tr-info-row a{margin-left:6px}.tr-info-row code{padding-inline:8px;max-height:80px;overflow-y:auto}.tr-info-tag{max-width:100%;text-wrap:balance;overflow:hidden;overflow-wrap:anywhere}.tr-info-title{display:inline-flex;align-items:center;justify-content:flex-start;column-gap:4px}.ant-tag-blue{background-color:#edf4fa;border-color:#a9c5e7;color:#0e49b5}.ant-tag-green{background-color:#eafff9;border-color:#76ccb4;color:#199270}.ant-tag-purple{background-color:#f2eaf1;border-color:#d5bed2;color:#7a316f}.ant-tag-orange,.ant-alert-warning{background-color:#ffeee1;border-color:#fec093;color:#f37b24}.ant-tag-red,.ant-alert-error{background-color:#ffe9e9;border-color:#ff9e9e;color:#cf3c3c}.ant-input::placeholder{opacity:.5}.ant-input:hover,.ant-input:focus{background-color:#e8f4f2}.ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){background-color:#e8f4f2}.delete-icon:hover{color:#e04141}.normal-icon:hover{color:var(--color-primary-100)}.dark ::-moz-selection{color:#fff;background-color:var(--color-primary-100)}.dark ::selection{color:#fff;background-color:var(--color-primary-100)}.dark .normal-icon:hover{color:#fff}.dark .ant-layout-sider,.dark .ant-drawer-content,.ant-menu-dark,.ant-menu-dark .ant-menu-sub,.dark .ant-card,.dark .ant-table,.dark .ant-collapse-content,.dark .ant-tabs{background-color:var(--dark-color-surface-100);color:var(--dark-color-text-primary)}.dark .ant-card-hoverable:hover,.dark .ant-space-item>.ant-tabs:hover{box-shadow:0 2px 8px #fff0}.dark>.ant-layout,.dark .drawer-handle,.dark .ant-table-thead>tr>th,.dark .ant-table-expanded-row,.dark .ant-table-expanded-row:hover,.dark .ant-table-expanded-row .ant-table-tbody,.dark .ant-calendar{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-table-expanded-row .ant-table-thead>tr:first-child>th{border-radius:0}.dark .ant-calendar,.dark .ant-card-bordered{border-color:var(--dark-color-background)}.dark .ant-table-bordered,.dark .ant-table-bordered.ant-table-empty .ant-table-placeholder,.dark .ant-table-bordered .ant-table-body>table,.dark .ant-table-bordered .ant-table-fixed-left table,.dark .ant-table-bordered .ant-table-fixed-right table,.dark .ant-table-bordered .ant-table-header>table,.dark .ant-table-bordered .ant-table-thead>tr:not(:last-child)>th,.dark .ant-table-bordered .ant-table-tbody>tr>td,.dark .ant-table-bordered .ant-table-thead>tr>th{border-color:var(--dark-color-surface-400)}.dark .ant-table-tbody>tr>td,.dark .ant-table-thead>tr>th,.dark .ant-card-head,.dark .ant-modal-header,.dark .ant-collapse>.ant-collapse-item,.dark .ant-tabs-bar,.dark .ant-list-split .ant-list-item,.dark .ant-popover-title,.dark .ant-calendar-header,.dark .ant-calendar-input-wrap{border-bottom-color:var(--dark-color-surface-400)}.dark .ant-modal-footer,.dark .ant-collapse-content,.dark .ant-calendar-footer,.dark .ant-divider-horizontal.ant-divider-with-text-left:before,.dark .ant-divider-horizontal.ant-divider-with-text-left:after,.dark .ant-divider-horizontal.ant-divider-with-text-center:before,.dark .ant-divider-horizontal.ant-divider-with-text-center:after{border-top-color:var(--dark-color-surface-300)}.ant-divider-horizontal.ant-divider-with-text-left:before{width:10%}.dark .ant-progress-text,.dark .ant-card-head,.dark .ant-form,.dark .ant-collapse>.ant-collapse-item>.ant-collapse-header,.dark .ant-modal-close-x,.dark .ant-form .anticon,.dark .ant-tabs-tab-arrow-show:not(.ant-tabs-tab-btn-disabled),.dark .anticon-close,.dark .ant-list-item-meta-title,.dark .ant-select-selection i,.dark .ant-modal-confirm-title,.dark .ant-modal-confirm-content,.dark .ant-popover-message,.dark .ant-modal,.dark .ant-divider-inner-text,.dark .ant-popover-title,.dark .ant-popover-inner-content,.dark h2,.dark .ant-modal-title,.dark .ant-form-item-label>label,.dark .ant-checkbox-wrapper,.dark .ant-form-item,.dark .ant-calendar-footer .ant-calendar-today-btn,.dark .ant-calendar-footer .ant-calendar-time-picker-btn,.dark .ant-calendar-day-select,.dark .ant-calendar-month-select,.dark .ant-calendar-year-select,.dark .ant-calendar-date,.dark .ant-calendar-year-panel-year,.dark .ant-calendar-month-panel-month,.dark .ant-calendar-decade-panel-decade{color:var(--dark-color-text-primary)}.dark .ant-pagination-options-size-changer .ant-select-arrow .anticon.anticon-down.ant-select-arrow-icon{color:rgb(255 255 255 / 35%)}.dark .ant-pagination-item a,.dark .ant-pagination-next a,.dark .ant-pagination-prev a{color:var(--dark-color-text-primary)}.dark .ant-pagination-item:focus a,.dark .ant-pagination-item:hover a,.dark .ant-pagination-item-active a,.dark .ant-pagination-next:hover .ant-pagination-item-link{color:var(--color-primary-100)}.dark .ant-pagination-item-active{background-color:#fff0}.dark .ant-list-item-meta-description{color:rgb(255 255 255 / .45)}.dark .ant-pagination-disabled i,.dark .ant-tabs-tab-btn-disabled{color:rgb(255 255 255 / .25)}.dark .ant-input,.dark .ant-input-group-addon,.dark .ant-collapse,.dark .ant-select-selection,.dark .ant-input-number,.dark .ant-input-number-handler-wrap,.dark .ant-table-placeholder,.dark .ant-empty-normal,.dark .ant-select-dropdown,.dark .ant-select-dropdown li,.dark .ant-select-dropdown-menu-item,.dark .client-table-header,.dark .ant-select-selection--multiple .ant-select-selection__choice{background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.dark .ant-select-dropdown--multiple .ant-select-dropdown-menu .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected :not(.ant-dropdown-menu-submenu-title:hover){background-color:var(--dark-color-surface-300)}.dark .ant-select-dropdown-menu-item.ant-select-dropdown-menu-item-selected{background-color:var(--dark-color-surface-300)}.dark .ant-calendar-time-picker-inner{background-color:var(--dark-color-background)}.dark .ant-select-selection:hover,.dark .ant-calendar-picker-clear,.dark .ant-input-number:hover,.dark .ant-input-number:focus,.dark .ant-input:hover,.dark .ant-input:focus{background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.dark .ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled){border-color:var(--color-primary-100);background-color:rgb(0 135 113 / .3)}.dark .ant-btn:not(.ant-btn-primary):not(.ant-btn-danger){color:var(--dark-color-text-primary);background-color:rgb(10 117 87 / 30%);border:1px solid var(--color-primary-100)}.dark .ant-radio-button-wrapper,.dark .ant-radio-button-wrapper:before{color:var(--dark-color-text-primary);background-color:rgb(0 135 113 / .3);border-color:var(--color-primary-100)}.ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){background-color:#e8f4f2}.dark .ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.dark .ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger){color:#fff;background-color:rgb(10 117 87 / 50%);border-color:var(--color-primary-100)}.dark .ant-btn-primary[disabled],.dark .ant-btn-danger[disabled],.dark .ant-calendar-ok-btn-disabled{color:rgb(255 255 255 / 35%);background-color:var(--dark-color-surface-200);border-color:var(--dark-color-surface-300)}.dark .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.dark .client-table-odd-row{background-color:var(--dark-color-table-hover)}.dark .ant-table-row-expand-icon{color:#fff;background-color:#fff0;border-color:rgb(255 255 255 / 20%)}.dark .ant-table-row-expand-icon:hover{color:var(--color-primary-100);background-color:#fff0;border-color:var(--color-primary-100)}.dark .ant-switch:not(.ant-switch-checked),.dark .ant-progress-line .ant-progress-inner{background-color:var(--dark-color-surface-500)}.dark .ant-progress-circle-trail{stroke:var(--dark-color-stroke)!important}.dark .ant-popover-inner{background-color:var(--dark-color-surface-500)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-500)}@media (max-width:768px){.dark .ant-popover-inner{background-color:var(--dark-color-surface-200)}.dark>.ant-popover-content>.ant-popover-arrow{border-color:var(--dark-color-surface-200)}}.ant-dropdown-menu-dark .ant-dropdown-menu-item:hover,.dark .ant-select-dropdown-menu-item-selected,.dark .ant-calendar-time-picker-select-option-selected{background-color:var(--dark-color-surface-600)}.ant-menu-dark .ant-menu-item:hover,.ant-menu-dark .ant-menu-submenu-title:hover{background-color:var(--dark-color-surface-300)}.dark .ant-menu-item:active,.dark .ant-menu-submenu-title:active{color:#fff;background-color:var(--dark-color-surface-300)}.dark .ant-alert-message{color:rgb(255 255 255 / .85)}.dark .ant-tag{color:var(--dark-color-tag-color);background-color:var(--dark-color-tag-bg);border-color:var(--dark-color-tag-border)}.dark .ant-tag-blue{background-color:var(--dark-color-tag-blue-bg);border-color:var(--dark-color-tag-blue-border);color:var(--dark-color-tag-blue-color)}.dark .ant-tag-red,.dark .ant-alert-error{background-color:var(--dark-color-tag-red-bg);border-color:var(--dark-color-tag-red-border);color:var(--dark-color-tag-red-color)}.dark .ant-tag-orange,.dark .ant-alert-warning{background-color:var(--dark-color-tag-orange-bg);border-color:var(--dark-color-tag-orange-border);color:var(--dark-color-tag-orange-color)}.dark .ant-tag-green{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border));color:var(--dark-color-tag-green-color)}.dark .ant-tag-purple{background-color:var(--dark-color-tag-purple-bg);border-color:var(--dark-color-tag-purple-border);color:var(--dark-color-tag-purple-color)}.dark .ant-modal-content,.dark .ant-modal-header{background-color:var(--dark-color-surface-700)}.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date{color:var(--dark-color-surface-300)}.dark .ant-calendar-selected-day .ant-calendar-date{background-color:var(--color-primary-100)!important;color:#fff}.dark .ant-calendar-date:hover,.dark .ant-calendar-time-picker-select li:hover{background-color:var(--dark-color-surface-600);color:#fff}.dark .ant-calendar-header a:hover,.dark .ant-calendar-header a:hover::before,.dark .ant-calendar-header a:hover::after{border-color:#fff}.dark .ant-calendar-time-picker-select{border-right-color:var(--dark-color-surface-300)}.has-warning .ant-select-selection,.has-warning .ant-select-selection:hover,.has-warning .ant-input,.has-warning .ant-input:hover{background-color:#ffeee1;border-color:#fec093}.has-warning .ant-input::placeholder{color:#f37b24}.has-warning .ant-input:not([disabled]):hover{border-color:#fec093}.dark .has-warning .ant-select-selection,.dark .has-warning .ant-select-selection:hover,.dark .has-warning .ant-input,.dark .has-warning .ant-input:hover{border-color:#784e1d;background:#312313}.dark .has-warning .ant-input::placeholder{color:rgb(255 160 49 / 70%)}.dark .has-warning .anticon{color:#ffa031}.dark .has-success .anticon{color:var(--color-primary-100);animation-name:diffZoomIn1!important}.dark .anticon-close-circle{color:#e04141}.dark .ant-spin-nested-loading>div>.ant-spin .ant-spin-text{text-shadow:0 1px 2px #0007}.dark .ant-spin{color:#fff}.dark .ant-spin-dot-item{background-color:#fff}.ant-checkbox-wrapper,.ant-input-group-addon,.ant-tabs-tab,.ant-input::placeholder,.ant-collapse-header,.ant-menu,.ant-radio-button-wrapper{-webkit-user-select:none;user-select:none}.ant-calendar-date,.ant-calendar-year-panel-year,.ant-calendar-decade-panel-decade,.ant-calendar-month-panel-month{border-radius:4px}.ant-checkbox-inner,.ant-checkbox-checked:after,.ant-table-row-expand-icon{border-radius:6px}.ant-calendar-date:hover{background-color:#e8f4f2}.ant-calendar-date:active{background-color:#e8f4f2;color:rgb(0 0 0 / .65)}.ant-calendar-today .ant-calendar-date{color:var(--color-primary-100);font-weight:400;border-color:var(--color-primary-100)}.dark .ant-calendar-today .ant-calendar-date{color:#fff;border-color:var(--color-primary-100)}.ant-calendar-selected-day .ant-calendar-date{background:var(--color-primary-100);color:#fff}li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(0 0 0 / .25)}.dark li.ant-select-dropdown-menu-item:empty:after{content:"None";font-weight:400;color:rgb(255 255 255 / .3)}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:rgb(0 0 0 / .87)}.dark.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item:hover .ant-select-selected-icon{color:#fff}.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected .ant-select-selected-icon,.ant-select-dropdown.ant-select-dropdown--multiple .ant-select-dropdown-menu-item-selected:hover .ant-select-selected-icon{color:var(--color-primary-100)}.ant-select-selection:hover,.ant-input-number-focused,.ant-input-number:hover{background-color:#e8f4f2}.dark .ant-input-number-handler:active{background-color:var(--color-primary-100)}.dark .ant-input-number-handler:hover .ant-input-number-handler-down-inner,.dark .ant-input-number-handler:hover .ant-input-number-handler-up-inner{color:#fff}.dark .ant-input-number-handler-down{border-top:1px solid rgb(217 217 217 / .3)}.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-century-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-decade-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-month-select,.dark .ant-calendar-year-panel-header .ant-calendar-year-panel-year-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-century-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-decade-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-month-select,.dark .ant-calendar-month-panel-header .ant-calendar-month-panel-year-select{color:rgb(255 255 255 / .85)}.dark .ant-calendar-year-panel-header{border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel-last-decade-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-next-decade-cell .ant-calendar-year-panel-year{color:rgb(255 255 255 / .35)}.dark .ant-divider:not(.ant-divider-with-text-center,.ant-divider-with-text-left,.ant-divider-with-text-right),.ant-dropdown-menu-dark,.dark .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-decade:hover{background-color:var(--dark-color-surface-200)}.dark .ant-calendar-header a:hover{color:#fff}.dark .ant-calendar-month-panel-header{background-color:var(--dark-color-background);border-bottom:1px solid var(--dark-color-surface-200)}.dark .ant-calendar-year-panel,.dark .ant-calendar table{background-color:var(--dark-color-background)}.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year,.dark .ant-calendar-year-panel-selected-cell .ant-calendar-year-panel-year:hover,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month,.dark .ant-calendar-month-panel-selected-cell .ant-calendar-month-panel-month:hover,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-selected-cell .ant-calendar-decade-panel-decade:hover{color:#fff;background-color:var(--color-primary-100)!important}.dark .ant-calendar-last-month-cell .ant-calendar-date,.dark .ant-calendar-last-month-cell .ant-calendar-date:hover,.dark .ant-calendar-next-month-btn-day .ant-calendar-date,.dark .ant-calendar-next-month-btn-day .ant-calendar-date:hover{color:rgb(255 255 255 / 25%);background:#fff0;border-color:#fff0}.dark .ant-calendar-today .ant-calendar-date:hover{color:#fff;border-color:var(--color-primary-100);background-color:var(--color-primary-100)}.dark .ant-calendar-decade-panel-last-century-cell .ant-calendar-decade-panel-decade,.dark .ant-calendar-decade-panel-next-century-cell .ant-calendar-decade-panel-decade{color:rgb(255 255 255 / 25%)}.dark .ant-calendar-decade-panel-header{border-bottom:1px solid var(--dark-color-surface-200);background-color:var(--dark-color-background)}.dark .ant-checkbox-inner{background-color:rgb(0 135 113 / .3);border-color:rgb(0 135 113 / .3)}.dark .ant-checkbox-checked .ant-checkbox-inner{background-color:var(--color-primary-100);border-color:var(--color-primary-100)}.dark .ant-calendar-input{background-color:var(--dark-color-background);color:var(--dark-color-text-primary)}.dark .ant-calendar-input::placeholder{color:rgb(255 255 255 / .25)}.ant-input-group.ant-input-group-compact-addon:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact-wrap:not(:first-child):not(:last-child),.ant-input-group.ant-input-group-compact>.ant-input:not(:first-child):not(:last-child),.ant-input-number-handler,.ant-input-number-handler-wrap{border-radius:0}.ant-input-number{overflow:clip}.ant-modal-body,.ant-collapse-content>.ant-collapse-content-box{overflow-x:auto}.ant-modal-body{overflow-y:hidden}.ant-calendar-year-panel-year:hover,.ant-calendar-decade-panel-decade:hover,.ant-calendar-month-panel-month:hover,.ant-dropdown-menu-item:hover,.ant-dropdown-menu-submenu-title:hover,.ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled),.ant-table-tbody>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,.ant-table-thead>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td{background-color:#e8f4f2}.dark .ant-dropdown-menu-submenu-title:hover,.dark .ant-select-dropdown-menu-item-active:not(.ant-select-dropdown-menu-item-disabled),.dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled){background-color:rgb(0 93 78 / .3)}.ant-select-dropdown,.ant-popover-inner{overflow-x:hidden}.ant-popover-inner-content{max-height:450px;overflow-y:auto}@media (max-height:900px){.ant-popover-inner-content{max-height:400px}}@media (max-height:768px){.ant-popover-inner-content{max-height:300px}}@media (max-width:768px){.ant-popover-inner-content{max-height:300px}}.qr-modal{display:flex;align-items:flex-end;gap:10px;flex-direction:column;flex-wrap:wrap;row-gap:24px}.qr-box{width:220px}.qr-cv{width:100%;height:100%}.dark .qr-cv{background-color:#fff;padding:1px;border-radius:0.25rem}.qr-bg{background-color:#fff;display:flex;justify-content:center;align-content:center;padding:.8rem;border-radius:1rem;border:solid 1px #e8e8e8;height:220px;width:220px;transition:all 0.1s}.qr-bg:hover{border-color:#76ccb4;background-color:#eafff9}.qr-bg:hover:active{border-color:#76ccb4;background-color:rgb(197 241 228 / 70%)}.dark .qr-bg{background-color:var(--dark-color-surface-700);border-color:var(--dark-color-surface-300)}.dark .qr-bg:hover{background-color:rgb(var(--dark-color-tag-green-bg));border-color:rgb(var(--dark-color-tag-green-border))}.dark .qr-bg:hover:active{background-color:#17322e}@property --tr-rotate{syntax:'';initial-value:45deg;inherits:false}.qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#76ccb4,transparent,#d5bed2);display:flex;justify-content:center;align-content:center;padding:1px;border-radius:1rem;height:220px;width:220px}.dark .qr-bg-sub{background-image:linear-gradient(var(--tr-rotate),#195141,transparent,#5a2969)}.qr-bg-sub:hover{animation:tr-rotate-gradient 3.5s linear infinite}@keyframes tr-rotate-gradient{from{--tr-rotate:45deg}to{--tr-rotate:405deg}}.qr-bg-sub-inner{background-color:#fff;padding:.8rem;border-radius:1rem;transition:all 0.1s}.qr-bg-sub-inner:hover{background-color:rgb(255 255 255 / 60%);backdrop-filter:blur(25px)}.qr-bg-sub-inner:hover:active{background-color:rgb(255 255 255 / 30%)}.dark .qr-bg-sub-inner{background-color:rgb(var(--dark-color-surface-700-rgb))}.dark .qr-bg-sub-inner:hover{background-color:rgba(var(--dark-color-surface-700-rgb),.5);backdrop-filter:blur(25px)}.dark .qr-bg-sub-inner:hover:active{background-color:rgba(var(--dark-color-surface-700-rgb),.2)}.qr-tag{text-align:center;margin-bottom:10px;width:100%;overflow:hidden;margin-inline:0}@media (min-width:769px){.qr-modal{flex-direction:row;max-width:680px}}.tr-marquee{justify-content:flex-start}.tr-marquee span{padding-right:25%;white-space:nowrap;transform-origin:center}@keyframes move-ltr{0%{transform:translateX(0)}100%{transform:translateX(-100%)}}.ant-input-group-addon:not(:first-child):not(:last-child){border-radius:0rem 1rem 1rem 0rem}b,strong{font-weight:500}.ant-collapse>.ant-collapse-item>.ant-collapse-header{padding:10px 16px 10px 40px}.dark .ant-message-notice-content{background-color:var(--dark-color-surface-200);border:1px solid var(--dark-color-surface-300);color:var(--dark-color-text-primary)}.ant-btn-danger{background-color:var(--dark-color-btn-danger);border-color:var(--dark-color-btn-danger-border)}.ant-btn-danger:focus,.ant-btn-danger:hover{background-color:var(--dark-color-btn-danger-hover);border-color:var(--dark-color-btn-danger-hover)}.dark .ant-alert-close-icon .anticon-close:hover{color:#fff}.ant-empty-small{margin:4px 0;background-color:transparent!important}.ant-empty-small .ant-empty-image{height:20px}.ant-menu-theme-switch,.ant-menu-theme-switch:hover{background-color:transparent!important;cursor:default!important}.dark .ant-tooltip-inner,.dark .ant-tooltip-arrow:before{background-color:var(--dark-color-tooltip)}.ant-select-sm .ant-select-selection__rendered{margin-left:10px}.ant-collapse{-moz-animation:collfade 0.3s ease;-webkit-animation:0.3s collfade 0.3s ease;animation:collfade 0.3s ease}@-webkit-keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}@keyframes collfade{0%{transform:scaleY(.8);transform-origin:0% 0%;opacity:0}100%{transform:scaleY(1);transform-origin:0% 0%;opacity:1}}.ant-table-tbody>tr>td{border-color:#f0f0f0}.ant-table-row-expand-icon{vertical-align:middle;margin-inline-end:8px;position:relative;transform:scale(.9411764705882353)}.ant-table-row-collapsed::before{transform:rotate(-180deg);top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-collapsed::after{transform:rotate(0deg);top:3px;bottom:3px;inset-inline-start:7px;width:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::before{top:7px;inset-inline-end:3px;inset-inline-start:3px;height:1px;position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-table-row-expanded::after{top:3px;bottom:3px;inset-inline-start:7px;width:1px;transform:rotate(90deg);position:absolute;background:currentcolor;transition:transform 0.3s ease-out;content:""}.ant-menu-theme-switch.ant-menu-item .ant-switch:not(.ant-switch-disabled):active:after,.ant-switch:not(.ant-switch-disabled):active:before{width:16px}.dark .ant-select-disabled .ant-select-selection{background:var(--dark-color-surface-100);border-color:var(--dark-color-surface-200);color:rgb(255 255 255 / .25)}.dark .ant-select-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .1)}.dark .ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up-disabled .anticon,.dark .ant-input-number-handler-down:hover.ant-input-number-handler-down-disabled .anticon,.dark .ant-input-number-handler-up:hover.ant-input-number-handler-up-disabled .anticon{color:rgb(255 255 255 / .25)}.dark .ant-input-number-handler-down:active.ant-input-number-handler-down-disabled,.dark .ant-input-number-handler-up:active.ant-input-number-handler-up-disabled{background-color:rgb(0 0 0 / .2)}.ant-menu-dark .ant-menu-inline.ant-menu-sub{background:var(--dark-color-surface-100);box-shadow:none}.dark .ant-layout-sider-trigger{background:var(--dark-color-surface-100);color:rgb(255 255 255 / 65%)}.ant-layout-sider{overflow:auto}.dark .ant-back-top-content{background-color:var(--dark-color-back-top)}.dark .ant-back-top-content:hover{background-color:var(--dark-color-back-top-hover)}.ant-calendar-time .ant-calendar-footer .ant-calendar-time-picker-btn{text-transform:capitalize}.ant-calendar{border-color:#fff0;border-width:0}.ant-calendar-time-picker-select li:focus,li.ant-calendar-time-picker-select-option-selected{color:rgb(0 0 0 / .65);font-weight:400;background-color:#e8f4f2}.dark li.ant-calendar-time-picker-select-option-selected{color:var(--dark-color-text-primary);font-weight:400}.dark .ant-calendar-time-picker-select li:focus{color:#fff;font-weight:400;background-color:var(--color-primary-100)}.ant-calendar-time-picker-select li:hover{background:#f5f5f5}.ant-calendar-date{transition:background .3s ease,color .3s ease}li.ant-calendar-time-picker-select-option-selected{margin-block:2px}.ant-calendar-time-picker-select{padding:4px}.ant-calendar-time-picker-select li{height:28px;line-height:28px;border-radius:4px}@media (min-width:769px){.index-page .ant-layout-content{margin:24px 16px}}.index-page .ant-card-dark h2{color:var(--dark-color-text-primary)}.index-page~div .ant-backup-list-item{gap:10px}.index-page~div .ant-version-list-item{--padding:12px;padding:var(--padding)!important;gap:var(--padding)}.index-page.dark~div .ant-version-list-item svg{color:var(--dark-color-text-primary)}.index-page.dark~div .ant-backup-list-item svg,.index-page.dark .ant-badge-status-text,.index-page.dark .ant-card-extra{color:var(--dark-color-text-primary)}.index-page.dark .ant-card-actions>li{color:rgb(255 255 255 / .55)}.index-page.dark~div .ant-radio-inner{background-color:var(--dark-color-surface-100);border-color:var(--dark-color-surface-600)}.index-page.dark~div .ant-radio-checked .ant-radio-inner{border-color:var(--color-primary-100)}.index-page.dark~div .ant-backup-list,.index-page.dark~div .ant-version-list,.index-page.dark .ant-card-actions,.index-page.dark .ant-card-actions>li:not(:last-child){border-color:var(--dark-color-stroke)}.index-page .ant-card-actions{background:#fff0}.index-page .ip-hidden{-webkit-user-select:none;-moz-user-select:none;user-select:none;filter:blur(10px)}.index-page .xray-running-animation .ant-badge-status-dot,.index-page .xray-processing-animation .ant-badge-status-dot{animation:runningAnimation 1.2s linear infinite}.index-page .xray-running-animation .ant-badge-status-processing:after{border-color:var(--color-primary-100)}.index-page .xray-stop-animation .ant-badge-status-processing:after{border-color:#fa8c16}.index-page .xray-error-animation .ant-badge-status-processing:after{border-color:#f5222d}@keyframes runningAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.index-page .card-placeholder{text-align:center;padding:30px 0;margin-top:10px;background:#fff0;border:none}@keyframes cardGrow{0%{opacity:0}100%{opacity:1}}.index-page .ant-row .ant-col .ant-card{animation:cardGrow .5s cubic-bezier(.34,1.56,.64,1) forwards;opacity:0}.index-page .ant-row .ant-col:nth-child(1) .ant-card{animation-delay:.15s}.index-page .ant-row .ant-col:nth-child(2) .ant-card{animation-delay:.45s}.index-page .ant-row .ant-col:nth-child(3) .ant-card{animation-delay:.75s}.index-page .ant-row .ant-col:nth-child(4) .ant-card{animation-delay:.3s}.index-page .ant-row .ant-col:nth-child(5) .ant-card{animation-delay:.6s}.index-page .ant-row .ant-col:nth-child(6) .ant-card{animation-delay:.9s}.index-page .ant-row .ant-col:nth-child(7) .ant-card{animation-delay:.36s}.index-page .ant-row .ant-col:nth-child(8) .ant-card{animation-delay:.66s}.index-page .ant-row .ant-col:nth-child(9) .ant-card{animation-delay:.96s}.index-page .ant-row .ant-col:nth-child(10) .ant-card{animation-delay:.54s}.index-page .ant-row .ant-col:nth-child(11) .ant-card{animation-delay:.84s}.index-page .ant-row .ant-col:nth-child(12) .ant-card{animation-delay:1.14s}.index-page .ant-row .ant-col:nth-child(13) .ant-card{animation-delay:.24s}.index-page .ant-row .ant-col:nth-child(14) .ant-card{animation-delay:.48s}.index-page .ant-row .ant-col:nth-child(15) .ant-card{animation-delay:.72s}.index-page~div .log-container{height:auto;max-height:500px;overflow:auto;margin-top:.5rem}#app.login-app *{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app.login-app h1{text-align:center;height:110px}#app.login-app .ant-form-item-children .ant-btn,#app.login-app .ant-input{height:50px;border-radius:30px}#app.login-app .ant-input-group-addon{border-radius:0 30px 30px 0;width:50px;font-size:18px}#app.login-app .ant-input-affix-wrapper .ant-input-prefix{left:23px}#app.login-app .ant-input-affix-wrapper .ant-input:not(:first-child){padding-left:50px}#app.login-app .centered{display:flex;text-align:center;align-items:center;justify-content:center;width:100%}#app.login-app .title{font-size:2rem;margin-block-end:2rem}#app.login-app .title b{font-weight:bold!important}#app.login-app{overflow:hidden}#app.login-app #login{animation:charge 0.5s both;background-color:#fff;border-radius:2rem;padding:4rem 3rem;transition:all 0.3s;user-select:none;-webkit-user-select:none;-moz-user-select:none}#app.login-app #login:hover{box-shadow:0 2px 8px rgb(0 0 0 / .09)}@keyframes charge{from{transform:translateY(5rem);opacity:0}to{transform:translateY(0);opacity:1}}#app.login-app .under{background-color:#c7ebe2;z-index:0}#app.login-app.dark .under{background-color:var(--dark-color-login-wave)}#app.login-app.dark #login{background-color:var(--dark-color-surface-100)}#app.login-app.dark h1{color:#fff}#app.login-app .ant-btn-primary-login{width:100%}#app.login-app .ant-btn-primary-login:focus,#app.login-app .ant-btn-primary-login:hover{color:#fff;background-color:#065;border-color:#065;background-image:linear-gradient(270deg,#fff0 30%,#009980,#fff0 100%);background-repeat:no-repeat;animation:ma-bg-move ease-in-out 5s infinite;background-position-x:-500px;width:95%;animation-delay:-0.5s;box-shadow:0 2px 0 rgb(0 0 0 / .045)}#app.login-app .ant-btn-primary-login.active,#app.login-app .ant-btn-primary-login:active{color:#fff;background-color:#065;border-color:#065}@keyframes ma-bg-move{0%{background-position:-500px 0}50%{background-position:1000px 0}100%{background-position:1000px 0}}#app.login-app .wave-btn-bg{position:relative;border-radius:25px;width:100%;transition:all 0.3s cubic-bezier(.645,.045,.355,1)}#app.login-app.dark .wave-btn-bg{color:#fff;position:relative;background-color:#0a7557;border:2px double #fff0;background-origin:border-box;background-clip:padding-box,border-box;background-size:300%;width:100%;z-index:1}#app.login-app.dark .wave-btn-bg:hover{animation:wave-btn-tara 4s ease infinite}#app.login-app.dark .wave-btn-bg-cl{background-image:linear-gradient(#fff0,#fff0),radial-gradient(circle at left top,#006655,#009980,#006655)!important;border-radius:3em}#app.login-app.dark .wave-btn-bg-cl:hover{width:95%}#app.login-app.dark .wave-btn-bg-cl:before{position:absolute;content:"";top:-5px;left:-5px;bottom:-5px;right:-5px;z-index:-1;background:inherit;background-size:inherit;border-radius:4em;opacity:0;transition:0.5s}#app.login-app.dark .wave-btn-bg-cl:hover::before{opacity:1;filter:blur(20px);animation:wave-btn-tara 8s linear infinite}@keyframes wave-btn-tara{to{background-position:300%}}#app.login-app.dark .ant-btn-primary-login{font-size:14px;color:#fff;text-align:center;background-image:linear-gradient(rgb(13 14 33 / .45),rgb(13 14 33 / .35));border-radius:2rem;border:none;outline:none;background-color:#fff0;height:46px;position:relative;white-space:nowrap;cursor:pointer;touch-action:manipulation;padding:0 15px;width:100%;animation:none;background-position-x:0;box-shadow:none}#app.login-app .waves-header{position:fixed;width:100%;text-align:center;background-color:#dbf5ed;color:#fff;z-index:-1}#app.login-app.dark .waves-header{background-color:var(--dark-color-login-background)}#app.login-app .waves-inner-header{height:50vh;width:100%;margin:0;padding:0}#app.login-app .waves{position:relative;width:100%;height:15vh;margin-bottom:-8px;min-height:100px;max-height:150px}#app.login-app .parallax>use{animation:move-forever 25s cubic-bezier(.55,.5,.45,.5) infinite}#app.login-app.dark .parallax>use{fill:var(--dark-color-login-wave)}#app.login-app .parallax>use:nth-child(1){animation-delay:-2s;animation-duration:4s;opacity:.2}#app.login-app .parallax>use:nth-child(2){animation-delay:-3s;animation-duration:7s;opacity:.4}#app.login-app .parallax>use:nth-child(3){animation-delay:-4s;animation-duration:10s;opacity:.6}#app.login-app .parallax>use:nth-child(4){animation-delay:-5s;animation-duration:13s}@keyframes move-forever{0%{transform:translate3d(-90px,0,0)}100%{transform:translate3d(85px,0,0)}}@media (max-width:768px){#app.login-app .waves{height:40px;min-height:40px}}#app.login-app .words-wrapper{width:100%;display:inline-block;position:relative;text-align:center}#app.login-app .words-wrapper b{width:100%;display:inline-block;position:absolute;left:0;top:0}#app.login-app .words-wrapper b.is-visible{position:relative}#app.login-app .headline.zoom .words-wrapper{-webkit-perspective:300px;-moz-perspective:300px;perspective:300px}#app.login-app .headline{display:flex;justify-content:center;align-items:center}#app.login-app .headline.zoom b{opacity:0}#app.login-app .headline.zoom b.is-visible{opacity:1;-webkit-animation:zoom-in 0.8s;-moz-animation:zoom-in 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-in 0.8s}#app.login-app .headline.zoom b.is-hidden{-webkit-animation:zoom-out 0.8s;-moz-animation:zoom-out 0.8s;animation:cubic-bezier(.215,.61,.355,1) zoom-out 0.4s}@-webkit-keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0)}}@-moz-keyframes zoom-in{0%{opacity:0;-moz-transform:translateZ(100px)}100%{opacity:1;-moz-transform:translateZ(0)}}@keyframes zoom-in{0%{opacity:0;-webkit-transform:translateZ(100px);-moz-transform:translateZ(100px);-ms-transform:translateZ(100px);-o-transform:translateZ(100px);transform:translateZ(100px)}100%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}}@-webkit-keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px)}}@-moz-keyframes zoom-out{0%{opacity:1;-moz-transform:translateZ(0)}100%{opacity:0;-moz-transform:translateZ(-100px)}}@keyframes zoom-out{0%{opacity:1;-webkit-transform:translateZ(0);-moz-transform:translateZ(0);-ms-transform:translateZ(0);-o-transform:translateZ(0);transform:translateZ(0)}100%{opacity:0;-webkit-transform:translateZ(-100px);-moz-transform:translateZ(-100px);-ms-transform:translateZ(-100px);-o-transform:translateZ(-100px);transform:translateZ(-100px)}}#app.login-app .setting-section{position:absolute;top:0;right:0;padding:22px}#app.login-app .ant-space-item .ant-switch{margin:2px 0 4px}#app.login-app .ant-layout-content{transition:none}.inbounds-page .ant-table:not(.ant-table-expanded-row .ant-table){outline:1px solid #f0f0f0;outline-offset:-1px;border-radius:1rem;overflow-x:hidden}.inbounds-page.dark .ant-table:not(.ant-table-expanded-row .ant-table){outline-color:var(--dark-color-table-ring)}.inbounds-page .ant-table .ant-table-content .ant-table-scroll .ant-table-body{overflow-y:hidden}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 22px!important}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper .ant-table{border-bottom-left-radius:1rem;border-bottom-right-radius:1rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child tr:last-child td{border-bottom-color:#fff0}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:first-child{border-bottom-left-radius:6px}.inbounds-page .ant-table .ant-table-tbody tr:last-child.ant-table-expanded-row .ant-table-wrapper .ant-table-tbody>tr:last-child>td:last-child{border-bottom-right-radius:6px}@media (min-width:769px){.inbounds-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.inbounds-page .ant-card-body{padding:.5rem}.inbounds-page .ant-table .ant-table-content .ant-table-tbody tr:last-child .ant-table-wrapper{margin:-10px 2px!important}}.inbounds-page.dark~div .ant-switch-small:not(.ant-switch-checked){background-color:var(--dark-color-surface-100)}.inbounds-page .ant-custom-popover-title{display:flex;align-items:center;gap:10px;margin:5px 0}.inbounds-page .ant-col-sm-24{margin:.5rem -2rem .5rem 2rem}.inbounds-page tr.hideExpandIcon .ant-table-row-expand-icon{display:none}.inbounds-page .infinite-tag,.inbounds-page~div .infinite-tag{padding:0 5px;border-radius:2rem;min-width:50px;min-height:22px}.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#F2EAF1;border:#D5BED2 solid 1px}.inbounds-page.dark .infinite-bar .ant-progress-inner .ant-progress-bg{background-color:#7a316f!important;border:#7a316f solid 1px}.inbounds-page~div .ant-collapse{margin:5px 0}.inbounds-page .info-large-tag,.inbounds-page~div .info-large-tag{max-width:200px;overflow:hidden}.inbounds-page .client-comment{font-size:12px;opacity:.75;cursor:help}.inbounds-page .client-email{font-weight:500}.inbounds-page .client-popup-item{display:flex;align-items:center;gap:5px}.inbounds-page .online-animation .ant-badge-status-dot{animation:onlineAnimation 1.2s linear infinite}@keyframes onlineAnimation{0%,50%,100%{transform:scale(1);opacity:1}10%{transform:scale(1.5);opacity:.2}}.inbounds-page .tr-table-box{display:flex;gap:4px;justify-content:center;align-items:center}.inbounds-page .tr-table-rt{flex-basis:70px;min-width:70px;text-align:end}.inbounds-page .tr-table-lt{flex-basis:70px;min-width:70px;text-align:start}.inbounds-page .tr-table-bar{flex-basis:160px;min-width:60px}.inbounds-page .tr-infinity-ch{font-size:14pt;max-height:24px;display:inline-flex;align-items:center}.inbounds-page .ant-table-expanded-row .ant-table .ant-table-body{overflow-x:hidden}.inbounds-page .ant-table-expanded-row .ant-table-tbody>tr>td{padding:10px 2px}.inbounds-page .ant-table-expanded-row .ant-table-thead>tr>th{padding:12px 2px}.idx-cpu-history-svg{display:block;overflow:unset!important}.dark .idx-cpu-history-svg .cpu-grid-line{stroke:rgb(255 255 255 / .08)}.dark .idx-cpu-history-svg .cpu-grid-h-line{stroke:rgb(255 255 255 / .25)}.dark .idx-cpu-history-svg .cpu-grid-y-text,.dark .idx-cpu-history-svg .cpu-grid-x-text{fill:rgb(200 200 200 / .8)}.idx-cpu-history-svg .cpu-grid-text{stroke-width:3;paint-order:stroke;stroke:rgb(0 0 0 / .05)}.dark .idx-cpu-history-svg .cpu-grid-text{fill:#fff;stroke:rgb(0 0 0 / .35)}.inbounds-page~div #inbound-modal form textarea.ant-input{margin:4px 0}@media (min-width:769px){.settings-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.settings-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}}.settings-page .ant-tabs-bar{margin:0}.settings-page .ant-list-item{display:block}.settings-page .alert-msg{color:#c27512;font-weight:400;font-size:16px;padding:.5rem 1rem;text-align:center;background:rgb(255 145 0 / 15%);margin:1.5rem 2.5rem 0rem;border-radius:.5rem;transition:all 0.5s;animation:settings-page-signal 3s cubic-bezier(.18,.89,.32,1.28) infinite}.settings-page .alert-msg:hover{cursor:default;transition-duration:.3s;animation:settings-page-signal 0.9s ease infinite}@keyframes settings-page-signal{0%{box-shadow:0 0 0 0 rgb(194 118 18 / .5)}50%{box-shadow:0 0 0 6px #fff0}100%{box-shadow:0 0 0 6px #fff0}}.settings-page .alert-msg>i{color:inherit;font-size:24px}.settings-page.dark .ant-input-password-icon{color:var(--dark-color-text-primary)}.settings-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}@media (min-width:769px){.xray-page .ant-layout-content{margin:24px 16px}}@media (max-width:768px){.xray-page .ant-tabs-nav .ant-tabs-tab{margin:0;padding:12px .5rem}.xray-page .ant-table-thead>tr>th,.xray-page .ant-table-tbody>tr>td{padding:10px 0}}.xray-page .ant-tabs-bar{margin:0}.xray-page .ant-list-item{display:block}.xray-page .ant-list-item>li{padding:10px 20px!important}.xray-page .ant-collapse-content-box .ant-alert{margin-block-end:12px}#app.login-app #login input.ant-input:-webkit-autofill{-webkit-box-shadow:0 0 0 100px #f8f8f8 inset;box-shadow:0 0 0 100px #f8f8f8 inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s;background-clip:text}#app.login-app #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app #login input.ant-input:-webkit-autofill:hover,#app.login-app #login input.ant-input:-webkit-autofill:focus{-webkit-box-shadow:0 0 0 100px #e8f4f2 inset;box-shadow:0 0 0 100px #e8f4f2 inset}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill{-webkit-text-fill-color:var(--dark-color-text-primary);caret-color:var(--dark-color-text-primary);-webkit-box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;box-shadow:0 0 0 1000px var(--dark-color-surface-200) inset;transition:background-color 9999s ease-in-out 0s,color 9999s ease-in-out 0s}#app.login-app.dark #login .ant-input-affix-wrapper:hover .ant-input:-webkit-autofill:not(.ant-input-disabled),#app.login-app.dark #login input.ant-input:-webkit-autofill:hover,#app.login-app.dark #login input.ant-input:-webkit-autofill:focus{border-color:var(--dark-color-surface-300)}.dark .ant-descriptions-bordered .ant-descriptions-item-label{background-color:var(--dark-color-background)}.dark .ant-descriptions-bordered .ant-descriptions-view,.dark .ant-descriptions-bordered .ant-descriptions-row,.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-list-bordered{border-color:var(--dark-color-surface-400)}.dark .ant-descriptions-bordered .ant-descriptions-item-label,.dark .ant-descriptions-bordered .ant-descriptions-item-content{color:var(--dark-color-text-primary)}.dark .ant-dropdown-menu{background-color:var(--dark-color-surface-200)}.dark .ant-dropdown-menu .ant-dropdown-menu-item{color:hsl(0 0% 100% / .65)}.dark .ant-dropdown-menu .ant-dropdown-menu-item:hover{background-color:var(--dark-color-surface-600)}.subscription-page .ant-list.ant-list-split.ant-list-bordered{overflow:hidden}.subscription-page .ant-list.ant-list-split.ant-list-bordered .ant-list-item{overflow-x:auto}.subscription-page .ant-btn.ant-btn-primary.ant-btn-lg.ant-dropdown-trigger{border-radius:4rem;padding:0 20px}.subscription-page .subscription-card{margin:2rem 0}.mb-10{margin-bottom:10px}.mb-12{margin-bottom:12px}.mt-5{margin-top:5px}.mr-8{margin-right:8px}.ml-10{margin-left:10px}.mr-05{margin-right:.5rem}.fs-1rem{font-size:1rem}.w-100{width:100%}.w-70{width:70px}.w-95{width:95px}.text-center{text-align:center}.cursor-pointer{cursor:pointer}.float-right{float:right}.va-middle{vertical-align:middle}.d-flex{display:flex}.justify-end{justify-content:flex-end}.max-w-400{max-width:400px;display:inline-block}.ant-space.jc-center{justify-content:center}.min-h-0{min-height:0}.min-h-100vh{min-height:100vh}.h-100{height:100%}.h-50px{height:50px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-auto{overflow-x:auto}.mt-1rem{margin-top:1rem}.my-3rem{margin-top:3rem;margin-bottom:3rem}.nodes-page .ant-table .ant-table-tbody>tr:last-child>td:first-child{border-bottom-left-radius:1rem}.nodes-page .ant-table .ant-table-tbody>tr:last-child>td:last-child{border-bottom-right-radius:1rem}@media (min-width:769px){.nodes-page .ant-layout-content{margin:24px 16px}}html[data-glass-morphism="true"]{background:#f0f0f3!important;background-attachment:fixed}html[data-glass-morphism="true"] body{background:#f0f0f3!important;background-color:#f0f0f3!important;color:#333!important;position:relative}html[data-glass-morphism="true"] body::before{content:'';position:fixed;inset:0;background:radial-gradient(circle at 25% 30%,#ffd6d6,transparent 40%),radial-gradient(circle at 75% 70%,#c8f7dc,transparent 40%),radial-gradient(circle at 50% 50%,#cce4ff,transparent 50%);background-size:200% 200%;animation:bgMove 25s linear infinite;z-index:-1;pointer-events:none}@keyframes bgMove{0%{background-position:0% 0%,100% 100%,50% 50%}50%{background-position:50% 50%,0% 100%,50% 50%}100%{background-position:0% 0%,100% 100%,50% 50%}}html[data-glass-morphism="true"]>.ant-layout,html[data-glass-morphism="true"] .ant-layout{background:transparent!important;background-color:transparent!important}html[data-glass-morphism="true"] .ant-layout-content{background:transparent!important;background-color:transparent!important}html[data-glass-morphism="true"] .ant-menu:not(.ant-menu-dark),html[data-glass-morphism="true"] .ant-menu-light{background:transparent!important;background-color:transparent!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-menu-item,html[data-glass-morphism="true"] .ant-menu-submenu-title{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-menu-item:hover,html[data-glass-morphism="true"] .ant-menu-submenu-title:hover{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-layout-sider:not(.ant-layout-sider-dark){background:transparent!important;background-color:transparent!important}html[data-glass-morphism="true"] .ant-layout-sider,html[data-glass-morphism="true"] .ant-menu-dark,html[data-glass-morphism="true"] .ant-menu-dark .ant-menu-sub,html[data-glass-morphism="true"] .ant-table,html[data-glass-morphism="true"] .ant-collapse-content,html[data-glass-morphism="true"] .ant-tabs,html[data-glass-morphism="true"] .ant-drawer-content{position:relative;background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(40px)!important;-webkit-backdrop-filter:blur(18px)!important;border:none!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;overflow:visible!important;overflow-x:visible!important;overflow-y:visible!important;transition:transform .5s ease,box-shadow .5s ease;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-table{position:relative;overflow:hidden}html[data-glass-morphism="true"] .ant-table>*{position:relative;z-index:2}html[data-glass-morphism="true"] .ant-table::after{z-index:1}html[data-glass-morphism="true"] .ant-table:hover::after{animation:appleSweep 1.2s cubic-bezier(.25,.46,.45,.94) forwards}@keyframes rotateBorder{0%{--angle:0deg}100%{--angle:360deg}}@keyframes floatGlass{0%{transform:translateY(0) rotateX(0) rotateY(0)}50%{transform:translateY(-4px) rotateX(1deg) rotateY(1deg)}100%{transform:translateY(0) rotateX(0) rotateY(0)}}html[data-glass-morphism="true"] .ant-card::after,html[data-glass-morphism="true"] .ant-table::after{content:'';position:absolute;inset:0;pointer-events:none;background:linear-gradient(135deg,transparent 0%,transparent 40%,rgba(255,255,255,.6) 50%,rgba(255,255,255,.4) 55%,transparent 65%,transparent 100%);opacity:0;transform:translate(-150%,-150%) scale(2);mix-blend-mode:overlay;z-index:1}html[data-glass-morphism="true"] .ant-card:hover::after,html[data-glass-morphism="true"] .ant-table:hover::after{animation:appleSweep 1.2s cubic-bezier(.25,.46,.45,.94) forwards}html[data-glass-morphism="true"] .ant-card{position:relative;background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:none!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;overflow:hidden;transition:transform .5s ease,box-shadow .5s ease}html[data-glass-morphism="true"] .ant-card>*{position:relative;z-index:2}html[data-glass-morphism="true"] .ant-card::after{z-index:1}html[data-glass-morphism="true"] .ant-card:hover::after{animation:appleSweep 1.2s cubic-bezier(.25,.46,.45,.94) forwards}html[data-glass-morphism="true"] .ant-table:hover{backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important}@keyframes appleSweep{0%{opacity:0;transform:translate(-150%,-150%) scale(2)}15%{opacity:1}85%{opacity:1}100%{opacity:0;transform:translate(50%,50%) scale(2)}}html[data-glass-morphism="true"] .ant-layout-sider{background:color-mix(in srgb,var(--c-glass) 8%,transparent)!important}html[data-glass-morphism="true"] .ant-card:hover,html[data-glass-morphism="true"] .ant-card-hoverable:hover,html[data-glass-morphism="true"] .ant-space-item>.ant-tabs:hover{box-shadow:0 30px 80px rgba(0,0,0,.25)!important}html[data-glass-morphism="true"] .ant-table:hover{box-shadow:0 25px 70px rgba(0,0,0,.2)!important}html[data-glass-morphism="true"] .ant-input,html[data-glass-morphism="true"] .ant-input-group-addon,html[data-glass-morphism="true"] .ant-collapse,html[data-glass-morphism="true"] .ant-select-selection,html[data-glass-morphism="true"] .ant-input-number,html[data-glass-morphism="true"] .ant-select-dropdown,html[data-glass-morphism="true"] .ant-select-dropdown-menu-item,html[data-glass-morphism="true"] .ant-select-selection--multiple .ant-select-selection__choice{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:24px!important;box-shadow:0 2px 10px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-input:hover,html[data-glass-morphism="true"] .ant-input:focus,html[data-glass-morphism="true"] .ant-select-selection:hover{background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,.1))!important;box-shadow:0 30px 80px rgba(0,0,0,.25)!important}html[data-glass-morphism="true"] .ant-btn:not(.ant-btn-primary):not(.ant-btn-danger){background:rgba(255,255,255,.12)!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;color:rgba(0,0,0,.85)!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important}html[data-glass-morphism="true"] .ant-btn:not(.ant-btn-primary):not(.ant-btn-danger):hover{background:rgba(255,255,255,.18)!important;box-shadow:0 30px 80px rgba(0,0,0,.25)!important}html[data-glass-morphism="true"] .ant-btn-primary{background:rgba(255,255,255,.12)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:none!important;color:rgba(0,0,0,.85)!important;border-radius:999px!important}html[data-glass-morphism="true"] .ant-btn-primary:hover{background:rgba(255,255,255,.18)!important;box-shadow:none!important}html[data-glass-morphism="true"] .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td{background:rgba(255,255,255,.3)!important}html[data-glass-morphism="true"] .ant-menu-item:hover,html[data-glass-morphism="true"] .ant-menu-submenu-title:hover{background:rgba(255,255,255,.5)!important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}html[data-glass-morphism="true"] .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{background:rgba(255,255,255,.12)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:none!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-popover-inner,html[data-glass-morphism="true"] .ant-tooltip-inner{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-dropdown-menu{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-dropdown-menu-item:hover{background:rgba(255,255,255,.18)!important}html[data-glass-morphism="true"] .ant-tag:not(.ant-tag-red):not(.ant-tag-orange):not(.ant-tag-green):not(.ant-tag-blue):not(.ant-tag-purple){background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-switch-checked{background:rgba(255,255,255,.12)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:none!important}html[data-glass-morphism="true"] .ant-checkbox-inner{background:color-mix(in srgb,var(--c-glass) 12%,transparent)!important;backdrop-filter:blur(5px) saturate(var(--saturation));-webkit-backdrop-filter:blur(5px) saturate(var(--saturation));border:none!important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*10%),transparent),inset 1.8px 3px 0 -2px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*90%),transparent),inset -2px -2px 0 -2px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*80%),transparent),inset -3px -8px 1px -6px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*60%),transparent),inset -0.3px -1px 4px 0 color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*12%),transparent),inset -1.5px 2.5px 0 -2px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*20%),transparent),inset 0 3px 4px -2px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*20%),transparent),inset 2px -6.5px 1px -4px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*10%),transparent),0 1px 5px 0 color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*10%),transparent),0 6px 16px 0 color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*8%),transparent)!important}html[data-glass-morphism="true"] .ant-checkbox-checked .ant-checkbox-inner{background:rgba(255,255,255,.18)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.3)!important;box-shadow:none!important}html[data-glass-morphism="true"] .ant-progress-inner{background:color-mix(in srgb,var(--c-glass) 8%,transparent)!important;backdrop-filter:blur(5px) saturate(var(--saturation));-webkit-backdrop-filter:blur(5px) saturate(var(--saturation));box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*10%),transparent)!important}html[data-glass-morphism="true"] .ant-progress-bg{background:rgba(255,255,255,.3)!important;box-shadow:none!important}html[data-glass-morphism="true"] .ant-layout-header{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border-bottom:1px solid rgba(255,255,255,.25)!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-input-affix-wrapper{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:24px!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important}html[data-glass-morphism="true"] .ant-input-affix-wrapper:hover,html[data-glass-morphism="true"] .ant-input-affix-wrapper-focused{background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,.1))!important;box-shadow:0 30px 80px rgba(0,0,0,.25)!important}html[data-glass-morphism="true"] .ant-input-affix-wrapper .ant-input{background:transparent!important;border:none!important;box-shadow:none!important}html[data-glass-morphism="true"] textarea.ant-input{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:24px!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important}html[data-glass-morphism="true"] .ant-modal-body .ant-input,html[data-glass-morphism="true"] .ant-modal-body .ant-input-affix-wrapper,html[data-glass-morphism="true"] .ant-modal-body textarea.ant-input,html[data-glass-morphism="true"] .ant-modal-body .ant-select-selection,html[data-glass-morphism="true"] .ant-modal-body .ant-input-number{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:24px!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-modal-content,html[data-glass-morphism="true"] .ant-modal-header{position:relative;background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:none!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;overflow:hidden;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-modal-title,html[data-glass-morphism="true"] .ant-modal-close-x,html[data-glass-morphism="true"] .ant-modal-body,html[data-glass-morphism="true"] .ant-modal-footer,html[data-glass-morphism="true"] .ant-modal-header-title{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-modal-body *{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-modal-body label,html[data-glass-morphism="true"] .ant-modal-body .ant-form-item-label>label{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-table-thead>tr>th{background:rgba(255,255,255,.3)!important;color:rgba(0,0,0,.85)!important;border-bottom-color:rgba(0,0,0,.1)!important}html[data-glass-morphism="true"] .ant-table-tbody>tr>td{color:rgba(0,0,0,.65)!important;border-bottom-color:rgba(0,0,0,.06)!important}html[data-glass-morphism="true"] .ant-card-head,html[data-glass-morphism="true"] .ant-card-head-title,html[data-glass-morphism="true"] .ant-card-extra{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-tabs-tab{color:rgba(0,0,0,.65)!important}html[data-glass-morphism="true"] .ant-tabs-tab-active{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] h1,html[data-glass-morphism="true"] h2,html[data-glass-morphism="true"] h3,html[data-glass-morphism="true"] h4,html[data-glass-morphism="true"] h5,html[data-glass-morphism="true"] h6{color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"]{background:#E8E8E9;background-attachment:fixed}@property --angle{syntax:'';initial-value:0deg;inherits:false}html[data-glass-morphism="true"] .ant-modal-mask{background:rgba(255,255,255,.1)!important;backdrop-filter:blur(20px)!important;-webkit-backdrop-filter:blur(20px)!important}html[data-glass-morphism="true"] .ant-table-placeholder{background:color-mix(in srgb,var(--c-glass) 12%,transparent)!important;backdrop-filter:blur(8px) saturate(var(--saturation));-webkit-backdrop-filter:blur(8px) saturate(var(--saturation));border:none!important;border-radius:0 0 1rem 1rem!important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*10%),transparent),inset 1.8px 3px 0 -2px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*90%),transparent),inset -2px -2px 0 -2px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*80%),transparent),inset -3px -8px 1px -6px color-mix(in srgb,var(--c-light) calc(var(--glass-reflex-light)*60%),transparent),inset -0.3px -1px 4px 0 color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*12%),transparent),inset -1.5px 2.5px 0 -2px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*20%),transparent),inset 0 3px 4px -2px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*20%),transparent),inset 2px -6.5px 1px -4px color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*10%),transparent),0 1px 5px 0 color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*10%),transparent),0 6px 16px 0 color-mix(in srgb,var(--c-dark) calc(var(--glass-reflex-dark)*8%),transparent)!important;color:rgba(0,0,0,.25)!important}html[data-glass-morphism="true"] .ant-tag-red,html[data-glass-morphism="true"] .ant-alert-error,html[data-glass-morphism="true"] .ant-tag-orange,html[data-glass-morphism="true"] .ant-alert-warning,html[data-glass-morphism="true"] .ant-tag-green,html[data-glass-morphism="true"] .ant-tag-blue,html[data-glass-morphism="true"] .ant-tag-purple{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-tag-red,html[data-glass-morphism="true"] .ant-alert-error{color:#cf3c3c!important}html[data-glass-morphism="true"] .ant-tag-orange,html[data-glass-morphism="true"] .ant-alert-warning{color:#f37b24!important}html[data-glass-morphism="true"] .ant-tag-green{background:rgba(255,255,255,.12)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.25)!important;color:rgba(0,0,0,.85)!important;box-shadow:none!important}html[data-glass-morphism="true"] .ant-radio-group-solid .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled),html[data-glass-morphism="true"] .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled){color:rgba(0,0,0,.85)!important;background:rgba(255,255,255,.12)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:none!important}html[data-glass-morphism="true"] .ant-tag-blue{color:#0e49b5!important}html[data-glass-morphism="true"] .ant-tag-purple{color:#7a316f!important}html[data-glass-morphism="true"] .ant-input,html[data-glass-morphism="true"] .ant-input-affix-wrapper,html[data-glass-morphism="true"] textarea.ant-input,html[data-glass-morphism="true"] .ant-select-selection,html[data-glass-morphism="true"] .ant-select-selector,html[data-glass-morphism="true"] .ant-input-number{border-radius:24px!important}html[data-glass-morphism="true"] .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected{background:rgba(255,255,255,.12)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;border:1px solid rgba(255,255,255,.25)!important;box-shadow:none!important;color:rgba(0,0,0,.85)!important;background-image:none!important;animation:none!important}html[data-glass-morphism="true"] .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):before,html[data-glass-morphism="true"] .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover:before{background-color:rgba(255,255,255,.25)!important}html[data-glass-morphism="true"] .ant-tabs-ink-bar{background-color:rgba(255,255,255,.25)!important;height:2px!important}html[data-glass-morphism="true"] .ant-progress-inner:not(.ant-progress-circle-gradient) .ant-progress-circle-path,html[data-glass-morphism="true"] .ant-progress-circle-path,html[data-glass-morphism="true"] .ant-progress-circle-trail{stroke:rgb(59 45 45 / 22%)!important}html[data-glass-morphism="true"] .index-page .xray-running-animation .ant-badge-status-processing:after{border-color:rgba(255,255,255,.3)!important}html[data-glass-morphism="true"] #app.login-app .under{background:transparent!important;background-color:transparent!important}html[data-glass-morphism="true"] #app.login-app .under::before{content:'';position:fixed;inset:0;background:radial-gradient(circle at 25% 30%,#ffd6d6,transparent 40%),radial-gradient(circle at 75% 70%,#c8f7dc,transparent 40%),radial-gradient(circle at 50% 50%,#cce4ff,transparent 50%);background-size:200% 200%;animation:bgMove 25s linear infinite;z-index:-1;pointer-events:none}html[data-glass-morphism="true"] #app.login-app #login{position:relative;background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:none!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;overflow:hidden;transition:transform .5s ease,box-shadow .5s ease}html[data-glass-morphism="true"] #app.login-app #login::after{content:'';position:absolute;inset:0;pointer-events:none;background:linear-gradient(135deg,transparent 0%,transparent 40%,rgba(255,255,255,.6) 50%,rgba(255,255,255,.4) 55%,transparent 65%,transparent 100%);opacity:0;transform:translate(-150%,-150%) scale(2);mix-blend-mode:overlay;z-index:1}html[data-glass-morphism="true"] #app.login-app #login:hover::after{animation:appleSweep 1.2s cubic-bezier(.25,.46,.45,.94) forwards}html[data-glass-morphism="true"] #app.login-app .waves-header{background:transparent!important;background-color:transparent!important}html[data-glass-morphism="true"] #app.login-app .waves{opacity:.3}html[data-glass-morphism="true"] .ant-layout.ant-layout-has-sider>.ant-layout,html[data-glass-morphism="true"] .ant-layout.ant-layout-has-sider>.ant-layout-content{position:relative;overflow-x:hidden}html[data-glass-morphism="true"] .ant-layout.ant-layout-has-sider>.ant-layout::before,html[data-glass-morphism="true"] .ant-layout.ant-layout-has-sider>.ant-layout-content::before{content:'';position:absolute;inset:0;background:radial-gradient(circle at 25% 30%,#ffd6d6,transparent 40%),radial-gradient(circle at 75% 70%,#c8f7dc,transparent 40%),radial-gradient(circle at 50% 50%,#cce4ff,transparent 50%);background-size:200% 200%;animation:bgMove 25s linear infinite;z-index:-1;pointer-events:none;opacity:.5}html[data-glass-morphism="true"] .ant-sidebar .ant-layout-sider{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:none!important;box-shadow:0 20px 60px rgba(0,0,0,.15)!important;overflow:hidden;transition:transform .5s ease,box-shadow .5s ease}html[data-glass-morphism="true"] .ant-layout-sider:not(.ant-layout-sider-collapsed){width:auto!important;min-width:200px!important;max-width:none!important}html[data-glass-morphism="true"] .ant-menu-item,html[data-glass-morphism="true"] .ant-menu-submenu-title,html[data-glass-morphism="true"] .ant-menu-theme-switch .ant-menu-item{white-space:nowrap!important;word-break:keep-all!important;overflow:visible!important}html[data-glass-morphism="true"] .ant-menu-item span,html[data-glass-morphism="true"] .ant-menu-submenu-title span,html[data-glass-morphism="true"] .ant-menu-theme-switch .ant-menu-item span{display:inline-block!important;max-width:none!important;vertical-align:middle!important;white-space:nowrap!important}html[data-glass-morphism="true"] .ant-message-notice-content{background:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.06))!important;backdrop-filter:blur(18px)!important;-webkit-backdrop-filter:blur(18px)!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:1rem!important;box-shadow:0 4px 12px rgba(0,0,0,.15)!important;color:rgba(0,0,0,.85)!important}html[data-glass-morphism="true"] .ant-spin-dot-item{background-color:rgba(255,255,255,.3)!important;backdrop-filter:blur(5px)!important;-webkit-backdrop-filter:blur(5px)!important} \ No newline at end of file diff --git a/web/assets/js/model/dbinbound.js b/web/assets/js/model/dbinbound.js index befc618e..195eb95a 100644 --- a/web/assets/js/model/dbinbound.js +++ b/web/assets/js/model/dbinbound.js @@ -20,11 +20,48 @@ class DBInbound { this.streamSettings = ""; this.tag = ""; this.sniffing = ""; - this.clientStats = "" + this.clientStats = ""; + this.nodeId = null; // Node ID for multi-node mode - DEPRECATED: kept only for backward compatibility, use nodeIds instead + this.nodeIds = []; // Node IDs array for multi-node mode - use this for multi-node support if (data == null) { return; } ObjectUtil.cloneProps(this, data); + // Ensure nodeIds is always an array (even if empty) + // Priority: use nodeIds if available, otherwise convert from deprecated nodeId + // First check if nodeIds exists and is an array (even if empty) + // Handle nodeIds from API response - it should be an array + if (this.nodeIds !== null && this.nodeIds !== undefined) { + if (Array.isArray(this.nodeIds)) { + // nodeIds is already an array - ensure all values are numbers + if (this.nodeIds.length > 0) { + this.nodeIds = this.nodeIds.map(id => { + // Convert string to number if needed + const numId = typeof id === 'string' ? parseInt(id, 10) : id; + return numId; + }).filter(id => !isNaN(id) && id > 0); + } else { + // Empty array is valid + this.nodeIds = []; + } + } else { + // nodeIds exists but is not an array - try to convert + // This shouldn't happen if API returns correct format, but handle it anyway + const nodeId = typeof this.nodeIds === 'string' ? parseInt(this.nodeIds, 10) : this.nodeIds; + this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : []; + } + } else if (this.nodeId !== null && this.nodeId !== undefined) { + // Convert deprecated nodeId to nodeIds array (backward compatibility) + const nodeId = typeof this.nodeId === 'string' ? parseInt(this.nodeId, 10) : this.nodeId; + this.nodeIds = !isNaN(nodeId) && nodeId > 0 ? [nodeId] : []; + } else { + // No nodes assigned - ensure empty array + this.nodeIds = []; + } + // Ensure nodeIds is never null or undefined - always an array + if (!Array.isArray(this.nodeIds)) { + this.nodeIds = []; + } } get totalGB() { @@ -116,6 +153,13 @@ class DBInbound { sniffing: sniffing, clientStats: this.clientStats, }; + // Include nodeIds if available (for multi-node mode) + if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) { + config.nodeIds = this.nodeIds; + } else if (this.nodeId !== null && this.nodeId !== undefined) { + // Backward compatibility: convert single nodeId to nodeIds array + config.nodeIds = [this.nodeId]; + } return Inbound.fromJson(config); } diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 8d4b6819..16ee5d34 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1075,6 +1075,8 @@ class Inbound extends XrayCommonClass { this.tag = tag; this.sniffing = sniffing; this.clientStats = clientStats; + this.nodeIds = []; // Node IDs array for multi-node mode + this.nodeId = null; // Backward compatibility } getClientStats() { return this.clientStats; @@ -1638,10 +1640,107 @@ class Inbound extends XrayCommonClass { } } + // Extract node host from node address (e.g., "http://192.168.1.100:8080" -> "192.168.1.100") + extractNodeHost(nodeAddress) { + if (!nodeAddress) return ''; + // Remove protocol prefix + let address = nodeAddress.replace(/^https?:\/\//, ''); + // Extract host (remove port if present) + const parts = address.split(':'); + return parts[0] || address; + } + + // Get node addresses from nodeIds - returns array of all node addresses + getNodeAddresses() { + // Check if we have nodeIds and availableNodes + if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) { + return []; + } + + // Try to get availableNodes from global app object + let availableNodes = null; + if (typeof app !== 'undefined' && app.availableNodes) { + availableNodes = app.availableNodes; + } else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) { + availableNodes = window.app.availableNodes; + } + + if (!availableNodes || availableNodes.length === 0) { + return []; + } + + // Get addresses for all node IDs + const addresses = []; + for (const nodeId of this.nodeIds) { + const node = availableNodes.find(n => n.id === nodeId); + if (node && node.address) { + const host = this.extractNodeHost(node.address); + if (host) { + addresses.push(host); + } + } + } + + return addresses; + } + + // Get node addresses with their IDs - returns array of {address, nodeId} + getNodeAddressesWithIds() { + // Check if we have nodeIds and availableNodes + if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) { + return []; + } + + // Try to get availableNodes from global app object + let availableNodes = null; + if (typeof app !== 'undefined' && app.availableNodes) { + availableNodes = app.availableNodes; + } else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) { + availableNodes = window.app.availableNodes; + } + + if (!availableNodes || availableNodes.length === 0) { + return []; + } + + // Get addresses with node IDs for all node IDs + const result = []; + for (const nodeId of this.nodeIds) { + const node = availableNodes.find(n => n.id === nodeId); + if (node && node.address) { + const host = this.extractNodeHost(node.address); + if (host) { + result.push({ address: host, nodeId: nodeId }); + } + } + } + + return result; + } + + // Get first node address (for backward compatibility) + getNodeAddress() { + const addresses = this.getNodeAddresses(); + return addresses.length > 0 ? addresses[0] : null; + } + genAllLinks(remark = '', remarkModel = '-ieo', client) { let result = []; let email = client ? client.email : ''; - let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + + // Get all node addresses with their IDs + const nodeAddressesWithIds = this.getNodeAddressesWithIds(); + + // Determine addresses to use + let addressesWithIds = []; + if (nodeAddressesWithIds.length > 0) { + addressesWithIds = nodeAddressesWithIds; + } else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { + addressesWithIds = [{ address: this.listen, nodeId: null }]; + } else { + addressesWithIds = [{ address: location.hostname, nodeId: null }]; + } + let port = this.port; const separationChar = remarkModel.charAt(0); const orderChars = remarkModel.slice(1); @@ -1650,19 +1749,26 @@ class Inbound extends XrayCommonClass { 'e': email, 'o': '', }; + if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) { - let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); - result.push({ - remark: r, - link: this.genLink(addr, port, 'same', r, client) + // Generate links for each node address + addressesWithIds.forEach((addrInfo) => { + let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); + result.push({ + remark: r, + link: this.genLink(addrInfo.address, port, 'same', r, client), + nodeId: addrInfo.nodeId + }); }); } else { + // External proxy takes precedence this.stream.externalProxy.forEach((ep) => { orders['o'] = ep.remark; let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); result.push({ remark: r, - link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client) + link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client), + nodeId: null }); }); } @@ -1670,7 +1776,18 @@ class Inbound extends XrayCommonClass { } genInboundLinks(remark = '', remarkModel = '-ieo') { - let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + // Get all node addresses + const nodeAddresses = this.getNodeAddresses(); + + // Determine addresses to use + let addresses = []; + if (nodeAddresses.length > 0) { + addresses = nodeAddresses; + } else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { + addresses = [this.listen]; + } else { + addresses = [location.hostname]; + } if (this.clients) { let links = []; this.clients.forEach((client) => { @@ -1680,11 +1797,20 @@ class Inbound extends XrayCommonClass { }); return links.join('\r\n'); } else { - if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark); + if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) { + // Generate links for each node address + let links = []; + addresses.forEach((addr) => { + links.push(this.genSSLink(addr, this.port, 'same', remark)); + }); + return links.join('\r\n'); + } if (this.protocol == Protocols.WIREGUARD) { let links = []; - this.settings.peers.forEach((p, index) => { - links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); + addresses.forEach((addr) => { + this.settings.peers.forEach((p, index) => { + links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); + }); }); return links.join('\r\n'); } @@ -1693,7 +1819,7 @@ class Inbound extends XrayCommonClass { } static fromJson(json = {}) { - return new Inbound( + const inbound = new Inbound( json.port, json.listen, json.protocol, @@ -1702,7 +1828,14 @@ class Inbound extends XrayCommonClass { json.tag, Sniffing.fromJson(json.sniffing), json.clientStats - ) + ); + // Restore nodeIds if present + if (json.nodeIds && Array.isArray(json.nodeIds)) { + inbound.nodeIds = json.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id); + } else if (json.nodeId !== null && json.nodeId !== undefined) { + inbound.nodeIds = [typeof json.nodeId === 'string' ? parseInt(json.nodeId, 10) : json.nodeId]; + } + return inbound; } toJson() { @@ -1710,7 +1843,7 @@ class Inbound extends XrayCommonClass { if (this.canEnableStream() || this.stream?.sockopt) { streamSettings = this.stream.toJson(); } - return { + const result = { port: this.port, listen: this.listen, protocol: this.protocol, @@ -1720,6 +1853,11 @@ class Inbound extends XrayCommonClass { sniffing: this.sniffing.toJson(), clientStats: this.clientStats }; + // Include nodeIds if present + if (this.nodeIds && Array.isArray(this.nodeIds) && this.nodeIds.length > 0) { + result.nodeIds = this.nodeIds; + } + return result; } } @@ -1764,7 +1902,7 @@ Inbound.Settings = class extends XrayCommonClass { Inbound.VmessSettings = class extends Inbound.Settings { constructor(protocol, - vmesses = [new Inbound.VmessSettings.VMESS()]) { + vmesses = []) { super(protocol); this.vmesses = vmesses; } @@ -1880,7 +2018,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { Inbound.VLESSSettings = class extends Inbound.Settings { constructor( protocol, - vlesses = [new Inbound.VLESSSettings.VLESS()], + vlesses = [], decryption = "none", encryption = "none", fallbacks = [], @@ -2070,7 +2208,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass { Inbound.TrojanSettings = class extends Inbound.Settings { constructor(protocol, - trojans = [new Inbound.TrojanSettings.Trojan()], + trojans = [], fallbacks = [],) { super(protocol); this.trojans = trojans; @@ -2235,7 +2373,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings { method = SSMethods.BLAKE3_AES_256_GCM, password = RandomUtil.randomShadowsocksPassword(), network = 'tcp,udp', - shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()], + shadowsockses = [], ivCheck = false, ) { super(protocol); diff --git a/web/assets/js/model/node.js b/web/assets/js/model/node.js new file mode 100644 index 00000000..41722157 --- /dev/null +++ b/web/assets/js/model/node.js @@ -0,0 +1,82 @@ +class Node { + constructor(data) { + this.id = 0; + this.name = ""; + this.address = ""; + this.apiKey = ""; + this.status = "unknown"; + this.lastCheck = 0; + this.createdAt = 0; + this.updatedAt = 0; + + if (data == null) { + return; + } + ObjectUtil.cloneProps(this, data); + } + + get isOnline() { + return this.status === "online"; + } + + get isOffline() { + return this.status === "offline"; + } + + get isError() { + return this.status === "error"; + } + + get isUnknown() { + return this.status === "unknown" || !this.status; + } + + get statusColor() { + switch (this.status) { + case 'online': return 'green'; + case 'offline': return 'red'; + case 'error': return 'red'; + default: return 'default'; + } + } + + get statusIcon() { + switch (this.status) { + case 'online': return 'check-circle'; + case 'offline': return 'close-circle'; + case 'error': return 'exclamation-circle'; + default: return 'question-circle'; + } + } + + get formattedLastCheck() { + if (!this.lastCheck || this.lastCheck === 0) { + return '-'; + } + const date = new Date(this.lastCheck * 1000); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; + } + + toJson() { + return { + id: this.id, + name: this.name, + address: this.address, + apiKey: this.apiKey, + status: this.status, + lastCheck: this.lastCheck, + createdAt: this.createdAt, + updatedAt: this.updatedAt + }; + } + + static fromJson(json) { + return new Node(json); + } +} diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index 53ffae1a..fbf1233b 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -72,10 +72,42 @@ class AllSetting { this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + // Multi-node mode settings + this.multiNodeMode = false; // Multi-node mode setting + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + this.hwidMode = "client_header"; // HWID tracking mode + if (data == null) { return } ObjectUtil.cloneProps(this, data); + + // Ensure multiNodeMode is boolean (handle string "true"/"false" from backend) + if (this.multiNodeMode !== undefined && this.multiNodeMode !== null) { + if (typeof this.multiNodeMode === 'string') { + this.multiNodeMode = this.multiNodeMode === 'true' || this.multiNodeMode === '1'; + } else { + this.multiNodeMode = Boolean(this.multiNodeMode); + } + } else { + this.multiNodeMode = false; + } + + // Ensure hwidMode is valid string (default to "client_header" if invalid) + if (this.hwidMode === undefined || this.hwidMode === null) { + this.hwidMode = "client_header"; + } else if (typeof this.hwidMode !== 'string') { + this.hwidMode = String(this.hwidMode); + } + // Validate hwidMode value + const validHwidModes = ["off", "client_header", "legacy_fingerprint"]; + if (!validHwidModes.includes(this.hwidMode)) { + this.hwidMode = "client_header"; // Default to client_header if invalid + } } equals(other) { diff --git a/web/cache/cache.go b/web/cache/cache.go new file mode 100644 index 00000000..a6b233d0 --- /dev/null +++ b/web/cache/cache.go @@ -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) +} diff --git a/web/cache/redis.go b/web/cache/redis.go new file mode 100644 index 00000000..69ebe914 --- /dev/null +++ b/web/cache/redis.go @@ -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 +} diff --git a/web/cache/redisstore.go b/web/cache/redisstore.go new file mode 100644 index 00000000..5a811646 --- /dev/null +++ b/web/cache/redisstore.go @@ -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() +} diff --git a/web/controller/api.go b/web/controller/api.go index 1a39f8ed..6978a3af 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -1,8 +1,13 @@ package controller import ( + "fmt" "net/http" + "regexp" + "strings" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/session" @@ -36,7 +41,12 @@ func (a *APIController) checkAPIAuth(c *gin.Context) { // initRouter sets up the API routes for inbounds, server, and other endpoints. func (a *APIController) initRouter(g *gin.RouterGroup) { - // Main API group + // Node push-logs endpoint (no session auth, uses API key) + // Register in separate group without session auth middleware + nodeAPI := g.Group("/panel/api/node") + nodeAPI.POST("/push-logs", a.pushNodeLogs) + + // Main API group with session auth api := g.Group("/panel/api") api.Use(a.checkAPIAuth) @@ -56,3 +66,149 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { func (a *APIController) BackuptoTgbot(c *gin.Context) { a.Tgbot.SendBackupToAdmins() } + +// extractPort extracts port number from URL address (e.g., "http://192.168.0.7:8080" -> "8080") +func extractPort(address string) string { + re := regexp.MustCompile(`:(\d+)(?:/|$)`) + matches := re.FindStringSubmatch(address) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// pushNodeLogs receives logs from a node in real-time and adds them to the panel log buffer. +// This endpoint is called by nodes when new logs are generated. +// It uses API key authentication instead of session authentication. +func (a *APIController) pushNodeLogs(c *gin.Context) { + type PushLogRequest struct { + ApiKey string `json:"apiKey" binding:"required"` // Node API key for authentication + NodeAddress string `json:"nodeAddress,omitempty"` // Node's own address for identification (optional, used when multiple nodes share API key) + Logs []string `json:"logs" binding:"required"` // Array of log lines in format "timestamp level - message" + } + + var req PushLogRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Find node by API key and optionally by address + nodeService := service.NodeService{} + nodes, err := nodeService.GetAllNodes() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get nodes"}) + return + } + + var node *model.Node + var matchedByKey []*model.Node // Track nodes with matching API key + + for _, n := range nodes { + if n.ApiKey == req.ApiKey { + matchedByKey = append(matchedByKey, n) + + // If nodeAddress is provided, match by both API key and address + if req.NodeAddress != "" { + // Normalize addresses for comparison (remove trailing slashes, etc.) + nodeAddr := strings.TrimSuffix(strings.TrimSpace(n.Address), "/") + reqAddr := strings.TrimSuffix(strings.TrimSpace(req.NodeAddress), "/") + + // Extract port from both addresses for comparison + // This handles cases where node uses localhost but panel has external IP + nodePort := extractPort(nodeAddr) + reqPort := extractPort(reqAddr) + + // Match by exact address or by port (if addresses don't match exactly) + // This allows nodes to use localhost while panel has external IP + if nodeAddr == reqAddr || (nodePort != "" && nodePort == reqPort) { + node = n + break + } + } else { + // If no address provided, use first match (backward compatibility) + node = n + break + } + } + } + + if node == nil { + // Enhanced logging for debugging + if len(matchedByKey) > 0 { + logger.Debugf("Failed to find node: API key matches %d node(s), but address mismatch. Request address: '%s', Request port: '%s'. Matched nodes: %v", + len(matchedByKey), req.NodeAddress, extractPort(req.NodeAddress), + func() []string { + var addrs []string + for _, n := range matchedByKey { + addrs = append(addrs, fmt.Sprintf("%s (port: %s)", n.Address, extractPort(n.Address))) + } + return addrs + }()) + } else { + logger.Debugf("Failed to find node: No node found with API key (received %d logs, key length: %d, key prefix: %s). Total nodes in DB: %d", + len(req.Logs), len(req.ApiKey), + func() string { + if len(req.ApiKey) > 4 { + return req.ApiKey[:4] + "..." + } + return req.ApiKey + }(), len(nodes)) + } + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + return + } + + // Log which node is sending logs (for debugging) + logger.Debugf("Received %d logs from node: %s (ID: %d, Address: %s, API key length: %d)", + len(req.Logs), node.Name, node.Id, node.Address, len(req.ApiKey)) + + // Process and add logs to panel buffer + for _, logLine := range req.Logs { + if logLine == "" { + continue + } + + // Parse log line: format is "timestamp level - message" + var level string + var message string + + if idx := strings.Index(logLine, " - "); idx != -1 { + parts := strings.SplitN(logLine, " - ", 2) + if len(parts) == 2 { + levelPart := strings.TrimSpace(parts[0]) + levelFields := strings.Fields(levelPart) + if len(levelFields) >= 2 { + level = strings.ToUpper(levelFields[len(levelFields)-1]) + message = parts[1] + } else { + level = "INFO" + message = parts[1] + } + } else { + level = "INFO" + message = logLine + } + } else { + level = "INFO" + message = logLine + } + + // Add log to panel buffer with node prefix + formattedMessage := fmt.Sprintf("[Node: %s] %s", node.Name, message) + switch level { + case "DEBUG": + logger.Debugf("%s", formattedMessage) + case "WARNING": + logger.Warningf("%s", formattedMessage) + case "ERROR": + logger.Errorf("%s", formattedMessage) + case "NOTICE": + logger.Noticef("%s", formattedMessage) + default: + logger.Infof("%s", formattedMessage) + } + } + + c.JSON(http.StatusOK, gin.H{"message": "Logs received"}) +} diff --git a/web/controller/client.go b/web/controller/client.go new file mode 100644 index 00000000..d0aba65f --- /dev/null +++ b/web/controller/client.go @@ -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) + } +} diff --git a/web/controller/client_hwid.go b/web/controller/client_hwid.go new file mode 100644 index 00000000..29ac79b3 --- /dev/null +++ b/web/controller/client_hwid.go @@ -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) +} diff --git a/web/controller/host.go b/web/controller/host.go new file mode 100644 index 00000000..d31a2303 --- /dev/null +++ b/web/controller/host.go @@ -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) +} diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 8317de31..a1b8be40 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -1,11 +1,14 @@ package controller import ( + "bytes" "encoding/json" "fmt" + "io" "strconv" "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/session" "github.com/mhsanaei/3x-ui/v2/web/websocket" @@ -103,12 +106,61 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) { // addInbound creates a new inbound configuration. func (a *InboundController) addInbound(c *gin.Context) { + // Try to get nodeIds from JSON body first (if Content-Type is application/json) + // This must be done BEFORE ShouldBind, which reads the body + var nodeIdsFromJSON []int + var nodeIdFromJSON *int + var hasNodeIdsInJSON, hasNodeIdInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract nodeIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract nodeIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for nodeIds array + if nodeIdsVal, ok := jsonData["nodeIds"]; ok { + hasNodeIdsInJSON = true + if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok { + for _, val := range nodeIdsArray { + if num, ok := val.(float64); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + } else if num, ok := nodeIdsVal.(float64); ok { + // Single number instead of array + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := nodeIdsVal.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + // Check for nodeId (backward compatibility) + if nodeIdVal, ok := jsonData["nodeId"]; ok { + hasNodeIdInJSON = true + if num, ok := nodeIdVal.(float64); ok { + nodeId := int(num) + nodeIdFromJSON = &nodeId + } else if num, ok := nodeIdVal.(int); ok { + nodeIdFromJSON = &num + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + inbound := &model.Inbound{} err := c.ShouldBind(inbound) if err != nil { + logger.Errorf("Failed to bind inbound data: %v", err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err) return } + user := session.GetLoginUser(c) inbound.UserId = user.Id if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { @@ -119,9 +171,65 @@ func (a *InboundController) addInbound(c *gin.Context) { inbound, needRestart, err := a.inboundService.AddInbound(inbound) if err != nil { + logger.Errorf("Failed to add inbound: %v", err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } + + // Handle node assignment in multi-node mode + nodeService := service.NodeService{} + + // Get nodeIds from form (for form-encoded requests) + nodeIdsStr := c.PostFormArray("nodeIds") + logger.Debugf("Received nodeIds from form: %v", nodeIdsStr) + + // Check if nodeIds array was provided (even if empty) + nodeIdStr := c.PostForm("nodeId") + + // Determine which source to use: JSON takes precedence over form data + useJSON := hasNodeIdsInJSON || hasNodeIdInJSON + useForm := (len(nodeIdsStr) > 0 || nodeIdStr != "") && !useJSON + + if useJSON || useForm { + var nodeIds []int + var nodeId *int + + if useJSON { + // Use data from JSON + nodeIds = nodeIdsFromJSON + nodeId = nodeIdFromJSON + } else { + // Parse nodeIds array from form + for _, idStr := range nodeIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + nodeIds = append(nodeIds, id) + } + } + } + // Parse single nodeId from form + if nodeIdStr != "" && nodeIdStr != "null" { + if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 { + nodeId = &parsedId + } + } + } + + if len(nodeIds) > 0 { + // Assign to multiple nodes + if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil { + logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + } else if nodeId != nil && *nodeId > 0 { + // Backward compatibility: single nodeId + if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil { + logger.Warningf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err) + } + } + } + jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil) if needRestart { a.xrayService.SetToNeedRestart() @@ -160,19 +268,151 @@ func (a *InboundController) updateInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } + + // Try to get nodeIds from JSON body first (if Content-Type is application/json) + var nodeIdsFromJSON []int + var nodeIdFromJSON *int + var hasNodeIdsInJSON, hasNodeIdInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract nodeIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract nodeIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for nodeIds array + if nodeIdsVal, ok := jsonData["nodeIds"]; ok { + hasNodeIdsInJSON = true + if nodeIdsArray, ok := nodeIdsVal.([]interface{}); ok { + for _, val := range nodeIdsArray { + if num, ok := val.(float64); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + } else if num, ok := nodeIdsVal.(float64); ok { + // Single number instead of array + nodeIdsFromJSON = append(nodeIdsFromJSON, int(num)) + } else if num, ok := nodeIdsVal.(int); ok { + nodeIdsFromJSON = append(nodeIdsFromJSON, num) + } + } + // Check for nodeId (backward compatibility) + if nodeIdVal, ok := jsonData["nodeId"]; ok { + hasNodeIdInJSON = true + if num, ok := nodeIdVal.(float64); ok { + nodeId := int(num) + nodeIdFromJSON = &nodeId + } else if num, ok := nodeIdVal.(int); ok { + nodeIdFromJSON = &num + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + // Get nodeIds from form (for form-encoded requests) + nodeIdsStr := c.PostFormArray("nodeIds") + logger.Debugf("Received nodeIds from form: %v (count: %d)", nodeIdsStr, len(nodeIdsStr)) + + // Check if nodeIds array was provided + nodeIdStr := c.PostForm("nodeId") + logger.Debugf("Received nodeId from form: %s", nodeIdStr) + + // Check if nodeIds or nodeId was explicitly provided in the form + _, hasNodeIds := c.GetPostForm("nodeIds") + _, hasNodeId := c.GetPostForm("nodeId") + logger.Debugf("Form has nodeIds: %v, has nodeId: %v", hasNodeIds, hasNodeId) + logger.Debugf("JSON has nodeIds: %v (values: %v), has nodeId: %v (value: %v)", hasNodeIdsInJSON, nodeIdsFromJSON, hasNodeIdInJSON, nodeIdFromJSON) + inbound := &model.Inbound{ Id: id, } + // Bind inbound data (nodeIds will be ignored since we handle it separately) err = c.ShouldBind(inbound) if err != nil { + logger.Errorf("Failed to bind inbound data: %v", err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) if err != nil { + logger.Errorf("Failed to update inbound: %v", err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } + + // Handle node assignment in multi-node mode + nodeService := service.NodeService{} + + // Determine which source to use: JSON takes precedence over form data + useJSON := hasNodeIdsInJSON || hasNodeIdInJSON + useForm := (hasNodeIds || hasNodeId) && !useJSON + + if useJSON || useForm { + var nodeIds []int + var nodeId *int + var hasNodeIdsFlag bool + + if useJSON { + // Use data from JSON + nodeIds = nodeIdsFromJSON + nodeId = nodeIdFromJSON + hasNodeIdsFlag = hasNodeIdsInJSON + } else { + // Use data from form + hasNodeIdsFlag = hasNodeIds + // Parse nodeIds array from form + for _, idStr := range nodeIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + nodeIds = append(nodeIds, id) + } else { + logger.Warningf("Invalid nodeId in array: %s (error: %v)", idStr, err) + } + } + } + // Parse single nodeId from form + if nodeIdStr != "" && nodeIdStr != "null" { + if parsedId, err := strconv.Atoi(nodeIdStr); err == nil && parsedId > 0 { + nodeId = &parsedId + } + } + } + + logger.Debugf("Parsed nodeIds: %v, nodeId: %v", nodeIds, nodeId) + + if len(nodeIds) > 0 { + // Assign to multiple nodes + if err := nodeService.AssignInboundToNodes(inbound.Id, nodeIds); err != nil { + logger.Errorf("Failed to assign inbound %d to nodes %v: %v", inbound.Id, nodeIds, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + logger.Debugf("Successfully assigned inbound %d to nodes %v", inbound.Id, nodeIds) + } else if nodeId != nil && *nodeId > 0 { + // Backward compatibility: single nodeId + if err := nodeService.AssignInboundToNode(inbound.Id, *nodeId); err != nil { + logger.Errorf("Failed to assign inbound %d to node %d: %v", inbound.Id, *nodeId, err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + logger.Debugf("Successfully assigned inbound %d to node %d", inbound.Id, *nodeId) + } else if hasNodeIdsFlag { + // nodeIds was explicitly provided but is empty - unassign all + if err := nodeService.UnassignInboundFromNode(inbound.Id); err != nil { + logger.Warningf("Failed to unassign inbound %d from nodes: %v", inbound.Id, err) + } else { + logger.Debugf("Successfully unassigned inbound %d from all nodes", inbound.Id) + } + } + // If neither nodeIds nor nodeId was provided, don't change assignments + } + jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil) if needRestart { a.xrayService.SetToNeedRestart() @@ -367,7 +607,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) { // onlines retrieves the list of currently online clients. func (a *InboundController) onlines(c *gin.Context) { - jsonObj(c, a.inboundService.GetOnlineClients(), nil) + clients := a.inboundService.GetOnlineClients() + jsonObj(c, clients, nil) } // lastOnline retrieves the last online timestamps for clients. diff --git a/web/controller/node.go b/web/controller/node.go new file mode 100644 index 00000000..895c9b6f --- /dev/null +++ b/web/controller/node.go @@ -0,0 +1,561 @@ +// Package controller provides HTTP handlers for node management in multi-node mode. +package controller + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/websocket" + + "github.com/gin-gonic/gin" +) + +// NodeController handles HTTP requests related to node management. +type NodeController struct { + nodeService service.NodeService +} + +// NewNodeController creates a new NodeController and sets up its routes. +func NewNodeController(g *gin.RouterGroup) *NodeController { + a := &NodeController{ + nodeService: service.NodeService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for node-related operations. +func (a *NodeController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getNodes) + g.GET("/get/:id", a.getNode) + g.POST("/add", a.addNode) + g.POST("/update/:id", a.updateNode) + g.POST("/del/:id", a.deleteNode) + g.POST("/check/:id", a.checkNode) + g.POST("/checkAll", a.checkAllNodes) + g.POST("/reload/:id", a.reloadNode) + g.POST("/reloadAll", a.reloadAllNodes) + g.GET("/status/:id", a.getNodeStatus) + g.POST("/logs/:id", a.getNodeLogs) + g.POST("/check-connection", a.checkNodeConnection) // Check node connection without API key + // push-logs endpoint moved to APIController to bypass session auth +} + +// getNodes retrieves the list of all nodes. +func (a *NodeController) getNodes(c *gin.Context) { + nodes, err := a.nodeService.GetAllNodes() + if err != nil { + jsonMsg(c, "Failed to get nodes", err) + return + } + + // Enrich nodes with assigned inbounds information + type NodeWithInbounds struct { + *model.Node + Inbounds []*model.Inbound `json:"inbounds,omitempty"` + } + + result := make([]NodeWithInbounds, 0, len(nodes)) + for _, node := range nodes { + inbounds, _ := a.nodeService.GetInboundsForNode(node.Id) + result = append(result, NodeWithInbounds{ + Node: node, + Inbounds: inbounds, + }) + } + + jsonObj(c, result, nil) +} + +// getNode retrieves a specific node by its ID. +func (a *NodeController) getNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + jsonObj(c, node, nil) +} + +// addNode creates a new node and registers it with a generated API key. +func (a *NodeController) addNode(c *gin.Context) { + node := &model.Node{} + err := c.ShouldBind(node) + if err != nil { + jsonMsg(c, "Invalid node data", err) + return + } + + // Log received data for debugging + logger.Debugf("[Node: %s] Adding node: address=%s", node.Name, node.Address) + + // Note: Connection check is done on frontend via /panel/node/check-connection endpoint + // to avoid CORS issues. Here we proceed directly to registration. + + // Generate API key and register node + apiKey, err := a.nodeService.RegisterNode(node) + if err != nil { + logger.Errorf("[Node: %s] Registration failed: %v", node.Name, err) + jsonMsg(c, "Failed to register node: "+err.Error(), err) + return + } + + // Set the generated API key + node.ApiKey = apiKey + + // Set default status + if node.Status == "" { + node.Status = "unknown" + } + + // Save node to database + err = a.nodeService.AddNode(node) + if err != nil { + jsonMsg(c, "Failed to add node to database", err) + return + } + + // Check health immediately + go a.nodeService.CheckNodeHealth(node) + + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + + logger.Infof("[Node: %s] Node added and registered successfully", node.Name) + jsonMsgObj(c, "Node added and registered successfully", node, nil) +} + +// updateNode updates an existing node. +func (a *NodeController) updateNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + // Get existing node first to preserve fields that are not being updated + existingNode, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get existing node", err) + return + } + + // Create node with only provided fields + node := &model.Node{Id: id} + + // Try to parse as JSON first (for API calls) + contentType := c.GetHeader("Content-Type") + if contentType == "application/json" { + var jsonData map[string]interface{} + if err := c.ShouldBindJSON(&jsonData); err == nil { + // Only set fields that are provided in JSON + if nameVal, ok := jsonData["name"].(string); ok && nameVal != "" { + node.Name = nameVal + } + if addressVal, ok := jsonData["address"].(string); ok && addressVal != "" { + node.Address = addressVal + } + if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" { + node.ApiKey = apiKeyVal + } + // TLS settings + if useTlsVal, ok := jsonData["useTls"].(bool); ok { + node.UseTLS = useTlsVal + } + if certPathVal, ok := jsonData["certPath"].(string); ok { + node.CertPath = certPathVal + } + if keyPathVal, ok := jsonData["keyPath"].(string); ok { + node.KeyPath = keyPathVal + } + if insecureTlsVal, ok := jsonData["insecureTls"].(bool); ok { + node.InsecureTLS = insecureTlsVal + } + } + } else { + // Parse as form data (default for web UI) + // Only extract fields that are actually provided + if name := c.PostForm("name"); name != "" { + node.Name = name + } + if address := c.PostForm("address"); address != "" { + node.Address = address + } + if apiKey := c.PostForm("apiKey"); apiKey != "" { + node.ApiKey = apiKey + } + // TLS settings + node.UseTLS = c.PostForm("useTls") == "true" || c.PostForm("useTls") == "on" + if certPath := c.PostForm("certPath"); certPath != "" { + node.CertPath = certPath + } + if keyPath := c.PostForm("keyPath"); keyPath != "" { + node.KeyPath = keyPath + } + node.InsecureTLS = c.PostForm("insecureTls") == "true" || c.PostForm("insecureTls") == "on" + } + + // Validate API key if it was changed + if node.ApiKey != "" && node.ApiKey != existingNode.ApiKey { + // Create a temporary node for validation + validationNode := &model.Node{ + Id: id, + Address: node.Address, + ApiKey: node.ApiKey, + } + if validationNode.Address == "" { + validationNode.Address = existingNode.Address + } + err = a.nodeService.ValidateApiKey(validationNode) + if err != nil { + jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err) + return + } + } + + err = a.nodeService.UpdateNode(node) + if err != nil { + jsonMsg(c, "Failed to update node", err) + return + } + + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + + jsonMsgObj(c, "Node updated successfully", node, nil) +} + +// deleteNode deletes a node by its ID. +func (a *NodeController) deleteNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + err = a.nodeService.DeleteNode(id) + if err != nil { + jsonMsg(c, "Failed to delete node", err) + return + } + + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + + jsonMsg(c, "Node deleted successfully", nil) +} + +// checkNode checks the health of a specific node. +func (a *NodeController) checkNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + err = a.nodeService.CheckNodeHealth(node) + if err != nil { + jsonMsg(c, "Node health check failed", err) + return + } + + // Broadcast nodes update via WebSocket (to update status and response time) + a.broadcastNodesUpdate() + + jsonMsgObj(c, "Node health check completed", node, nil) +} + +// checkAllNodes checks the health of all nodes. +func (a *NodeController) checkAllNodes(c *gin.Context) { + a.nodeService.CheckAllNodesHealth() + // Broadcast nodes update after health check (with delay to allow all checks to complete) + go func() { + time.Sleep(3 * time.Second) // Wait for health checks to complete + a.broadcastNodesUpdate() + }() + jsonMsg(c, "Health check initiated for all nodes", nil) +} + +// getNodeStatus retrieves the detailed status of a node. +func (a *NodeController) getNodeStatus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + status, err := a.nodeService.GetNodeStatus(node) + if err != nil { + jsonMsg(c, "Failed to get node status", err) + return + } + + jsonObj(c, status, nil) +} + +// reloadNode reloads XRAY on a specific node. +func (a *NodeController) reloadNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + // Use force reload to handle hung nodes + err = a.nodeService.ForceReloadNode(node) + if err != nil { + jsonMsg(c, "Failed to reload node", err) + return + } + + jsonMsg(c, "Node reloaded successfully", nil) +} + +// reloadAllNodes reloads XRAY on all nodes. +func (a *NodeController) reloadAllNodes(c *gin.Context) { + err := a.nodeService.ReloadAllNodes() + if err != nil { + jsonMsg(c, "Failed to reload some nodes", err) + return + } + + jsonMsg(c, "All nodes reloaded successfully", nil) +} + +// getNodeLogs retrieves XRAY logs from a specific node. +func (a *NodeController) getNodeLogs(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + count := c.DefaultPostForm("count", "100") + filter := c.PostForm("filter") + showDirect := c.DefaultPostForm("showDirect", "true") + showBlocked := c.DefaultPostForm("showBlocked", "true") + showProxy := c.DefaultPostForm("showProxy", "true") + + countInt, _ := strconv.Atoi(count) + + // Get raw logs from node + rawLogs, err := a.nodeService.GetNodeLogs(node, countInt, filter) + if err != nil { + jsonMsg(c, "Failed to get logs from node", err) + return + } + + // Parse logs into LogEntry format (similar to ServerService.GetXrayLogs) + type LogEntry struct { + DateTime time.Time `json:"DateTime"` + FromAddress string `json:"FromAddress"` + ToAddress string `json:"ToAddress"` + Inbound string `json:"Inbound"` + Outbound string `json:"Outbound"` + Email string `json:"Email"` + Event int `json:"Event"` + } + + const ( + Direct = iota + Blocked + Proxied + ) + + var freedoms []string + var blackholes []string + + // Get tags for freedom and blackhole outbounds from default config + settingService := service.SettingService{} + config, err := settingService.GetDefaultXrayConfig() + if err == nil && config != nil { + if cfgMap, ok := config.(map[string]any); ok { + if outbounds, ok := cfgMap["outbounds"].([]any); ok { + for _, outbound := range outbounds { + if obMap, ok := outbound.(map[string]any); ok { + switch obMap["protocol"] { + case "freedom": + if tag, ok := obMap["tag"].(string); ok { + freedoms = append(freedoms, tag) + } + case "blackhole": + if tag, ok := obMap["tag"].(string); ok { + blackholes = append(blackholes, tag) + } + } + } + } + } + } + } + + if len(freedoms) == 0 { + freedoms = []string{"direct"} + } + if len(blackholes) == 0 { + blackholes = []string{"blocked"} + } + + var entries []LogEntry + for _, line := range rawLogs { + var entry LogEntry + parts := strings.Fields(line) + + for i, part := range parts { + if i == 0 && len(parts) > 1 { + dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local) + if err == nil { + entry.DateTime = dateTime.UTC() + } + } + + if part == "from" && i+1 < len(parts) { + entry.FromAddress = strings.TrimLeft(parts[i+1], "/") + } else if part == "accepted" && i+1 < len(parts) { + entry.ToAddress = strings.TrimLeft(parts[i+1], "/") + } else if strings.HasPrefix(part, "[") { + entry.Inbound = part[1:] + } else if strings.HasSuffix(part, "]") { + entry.Outbound = part[:len(part)-1] + } else if part == "email:" && i+1 < len(parts) { + entry.Email = parts[i+1] + } + } + + // Determine event type + logEntryContains := func(line string, suffixes []string) bool { + for _, sfx := range suffixes { + if strings.Contains(line, sfx+"]") { + return true + } + } + return false + } + + if logEntryContains(line, freedoms) { + if showDirect == "false" { + continue + } + entry.Event = Direct + } else if logEntryContains(line, blackholes) { + if showBlocked == "false" { + continue + } + entry.Event = Blocked + } else { + if showProxy == "false" { + continue + } + entry.Event = Proxied + } + + entries = append(entries, entry) + } + + jsonObj(c, entries, nil) +} + +// checkNodeConnection checks if a node is reachable (health check without API key). +// This is used during node registration to verify connectivity before registration. +func (a *NodeController) checkNodeConnection(c *gin.Context) { + type CheckConnectionRequest struct { + Address string `json:"address" form:"address" binding:"required"` + } + + var req CheckConnectionRequest + // HttpUtil.post sends data as form-urlencoded (see axios-init.js) + // So we use ShouldBind which handles both form and JSON + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request: "+err.Error(), err) + return + } + + if req.Address == "" { + jsonMsg(c, "Address is required", nil) + return + } + + // Create a temporary node object for health check + tempNode := &model.Node{ + Address: req.Address, + } + + // Check node health (this only uses /health endpoint, no API key required) + status, responseTime, err := a.nodeService.CheckNodeStatus(tempNode) + if err != nil { + jsonMsg(c, "Node is not reachable: "+err.Error(), err) + return + } + + if status != "online" { + jsonMsg(c, "Node is not online (status: "+status+")", nil) + return + } + + // Return response time along with success message + jsonMsgObj(c, fmt.Sprintf("Node is reachable (response time: %d ms)", responseTime), map[string]interface{}{ + "responseTime": responseTime, + }, nil) +} + +// broadcastNodesUpdate broadcasts the current nodes list to all WebSocket clients +func (a *NodeController) broadcastNodesUpdate() { + // Get all nodes with their inbounds + nodes, err := a.nodeService.GetAllNodes() + if err != nil { + logger.Warningf("Failed to get nodes for WebSocket broadcast: %v", err) + return + } + + // Enrich nodes with assigned inbounds information + type NodeWithInbounds struct { + *model.Node + Inbounds []*model.Inbound `json:"inbounds,omitempty"` + } + + result := make([]NodeWithInbounds, 0, len(nodes)) + for _, node := range nodes { + inbounds, _ := a.nodeService.GetInboundsForNode(node.Id) + result = append(result, NodeWithInbounds{ + Node: node, + Inbounds: inbounds, + }) + } + + // Broadcast via WebSocket + websocket.BroadcastNodes(result) +} diff --git a/web/controller/server.go b/web/controller/server.go index d32209e1..fc2f915a 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -237,7 +237,8 @@ func (a *ServerController) getXrayLogs(c *gin.Context) { blackholes = []string{"blocked"} } - logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes) + nodeId := c.PostForm("nodeId") + logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes, nodeId) jsonObj(c, logs, nil) } diff --git a/web/controller/util.go b/web/controller/util.go index b11203bd..9b39581e 100644 --- a/web/controller/util.go +++ b/web/controller/util.go @@ -8,6 +8,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/entity" + "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/gin-gonic/gin" ) @@ -92,6 +93,16 @@ func getContext(h gin.H) gin.H { a := gin.H{ "cur_ver": config.GetVersion(), } + + // Add multiNodeMode to context for all pages + settingService := service.SettingService{} + multiNodeMode, err := settingService.GetMultiNodeMode() + if err != nil { + // If error, default to false (single mode) + multiNodeMode = false + } + a["multiNodeMode"] = multiNodeMode + for key, value := range h { a[key] = value } diff --git a/web/controller/xui.go b/web/controller/xui.go index 51502900..137687eb 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -10,6 +10,7 @@ type XUIController struct { settingController *SettingController xraySettingController *XraySettingController + nodeController *NodeController } // NewXUIController creates a new XUIController and initializes its routes. @@ -28,9 +29,18 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/inbounds", a.inbounds) g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) + g.GET("/nodes", a.nodes) + g.GET("/clients", a.clients) + g.GET("/hosts", a.hosts) a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) + a.nodeController = NewNodeController(g.Group("/node")) + + // Register client and host controllers directly under /panel (not /panel/api) + NewClientController(g.Group("/client")) + NewHostController(g.Group("/host")) + NewClientHWIDController(g.Group("/client")) // Register HWID controller under /panel/client/hwid } // index renders the main panel index page. @@ -52,3 +62,18 @@ func (a *XUIController) settings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) { html(c, "xray.html", "pages.xray.title", nil) } + +// nodes renders the nodes management page (multi-node mode). +func (a *XUIController) nodes(c *gin.Context) { + html(c, "nodes.html", "pages.nodes.title", nil) +} + +// clients renders the clients management page. +func (a *XUIController) clients(c *gin.Context) { + html(c, "clients.html", "pages.clients.title", nil) +} + +// hosts renders the hosts management page (multi-node mode). +func (a *XUIController) hosts(c *gin.Context) { + html(c, "hosts.html", "pages.hosts.title", nil) +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 42e2df85..31eb3aeb 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -98,6 +98,15 @@ type AllSetting struct { LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"` + + // Multi-node mode setting + MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + HwidMode string `json:"hwidMode" form:"hwidMode"` // HWID tracking mode // JSON subscription routing rules } @@ -168,5 +177,15 @@ func (s *AllSetting) CheckValid() error { return common.NewError("time location not exist:", s.TimeLocation) } + // Validate HWID mode + validHwidModes := map[string]bool{ + "off": true, + "client_header": true, + "legacy_fingerprint": true, + } + if s.HwidMode != "" && !validHwidModes[s.HwidMode] { + return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", s.HwidMode) + } + return nil } diff --git a/web/html/clients.html b/web/html/clients.html new file mode 100644 index 00000000..5c7c3419 --- /dev/null +++ b/web/html/clients.html @@ -0,0 +1,942 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + + + + + + + + +
+ + + + + + + {{ i18n "none" }} + {{ i18n "disabled" }} + {{ i18n "depleted" }} + {{ i18n "depletingSoon" }} + {{ i18n "online" }} + +
+ + + + + + + + + +
+
+
+
+ + + + + +
+
+
+
+
+ +{{template "page/body_scripts" .}} + + + + +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} +{{template "modals/qrcodeModal"}} +{{template "modals/clientEntityModal"}} + +{{ template "page/body_end" .}} diff --git a/web/html/component/aClientTable.html b/web/html/component/aClientTable.html index d9a9b5f5..18e4294e 100644 --- a/web/html/component/aClientTable.html +++ b/web/html/component/aClientTable.html @@ -12,13 +12,6 @@ - - - - - - - @@ -26,13 +32,17 @@ {{end}} @@ -40,10 +50,34 @@ {{define "component/aThemeSwitch"}} +{{ template "page/body_end" .}} diff --git a/web/html/inbounds.html b/web/html/inbounds.html index eeffd98d..0156f9bd 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -123,18 +123,6 @@ {{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }} - - - {{ i18n "pages.inbounds.resetAllTraffic" }} - - - - {{ i18n "pages.inbounds.resetAllClientTraffics" }} - - - - {{ i18n "pages.inbounds.delDepletedClients" }} - @@ -204,18 +192,6 @@ {{ i18n "qrCode" }} - - [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] / - - - + diff --git a/web/html/modals/client_entity_modal.html b/web/html/modals/client_entity_modal.html new file mode 100644 index 00000000..bcad799b --- /dev/null +++ b/web/html/modals/client_entity_modal.html @@ -0,0 +1,307 @@ +{{define "modals/clientEntityModal"}} + + + + + + + + + + + + + + + + + + {{ i18n "none" }} + [[ key ]] + + + + + {{ i18n "none" }} + [[ key ]] + + + + + + + + + + + + + + + + + + + + + + + + + + [[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]]) + + + + {{ i18n "hwidSettings" }} + + + + + + + + + + + + + + + + + + + + + + + + + +{{end}} diff --git a/web/html/modals/client_modal.html b/web/html/modals/client_modal.html index 8b57b8b2..9e4c2f92 100644 --- a/web/html/modals/client_modal.html +++ b/web/html/modals/client_modal.html @@ -1,4 +1,9 @@ {{define "modals/clientsModal"}} + +{{end}} diff --git a/web/html/modals/inbound_info_modal.html b/web/html/modals/inbound_info_modal.html index 72023e75..14673c48 100644 --- a/web/html/modals/inbound_info_modal.html +++ b/web/html/modals/inbound_info_modal.html @@ -23,6 +23,19 @@ [[ dbInbound.port ]] + + Nodes + + + + @@ -508,8 +521,17 @@ clientIps: '', show(dbInbound, index) { this.index = index; - this.inbound = dbInbound.toInbound(); + // Create DBInbound first to ensure nodeIds are properly processed this.dbInbound = new DBInbound(dbInbound); + // Ensure nodeIds are properly set - they should be an array + if (!Array.isArray(this.dbInbound.nodeIds)) { + this.dbInbound.nodeIds = []; + } + this.inbound = this.dbInbound.toInbound(); + // Ensure inbound also has nodeIds from dbInbound + if (this.dbInbound.nodeIds && Array.isArray(this.dbInbound.nodeIds) && this.dbInbound.nodeIds.length > 0) { + this.inbound.nodeIds = this.dbInbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0); + } this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null; this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry; this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null; @@ -563,6 +585,12 @@ get inbound() { return this.infoModal.inbound; }, + get multiNodeMode() { + return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false; + }, + get availableNodes() { + return app && app.availableNodes || []; + }, get isActive() { if (infoModal.clientStats) { return infoModal.clientStats.enable; @@ -629,6 +657,10 @@ }) .catch(() => {}); }, + getNodeName(nodeId) { + const node = this.availableNodes.find(n => n.id === nodeId); + return node ? node.name : null; + }, }, }); diff --git a/web/html/modals/inbound_modal.html b/web/html/modals/inbound_modal.html index c3883285..dad31a41 100644 --- a/web/html/modals/inbound_modal.html +++ b/web/html/modals/inbound_modal.html @@ -22,11 +22,13 @@ show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) { this.title = title; this.okText = okText; + if (inbound) { this.inbound = Inbound.fromJson(inbound.toJson()); } else { this.inbound = new Inbound(); } + // Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet) // This ensures Vue reactivity works properly if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) { @@ -35,14 +37,42 @@ this.inbound.settings.testseed = [900, 500, 900, 256].slice(); } } + if (dbInbound) { this.dbInbound = new DBInbound(dbInbound); } else { this.dbInbound = new DBInbound(); } + + // Set nodeIds - ensure it's always an array for Vue reactivity + let nodeIdsToSet = []; + if (dbInbound) { + const dbInboundObj = new DBInbound(dbInbound); + if (dbInboundObj.nodeIds && Array.isArray(dbInboundObj.nodeIds) && dbInboundObj.nodeIds.length > 0) { + nodeIdsToSet = dbInboundObj.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0); + } else if (dbInboundObj.nodeId !== null && dbInboundObj.nodeId !== undefined) { + const nodeId = typeof dbInboundObj.nodeId === 'string' ? parseInt(dbInboundObj.nodeId, 10) : dbInboundObj.nodeId; + if (!isNaN(nodeId) && nodeId > 0) { + nodeIdsToSet = [nodeId]; + } + } + } else if (inbound && inbound.nodeIds && Array.isArray(inbound.nodeIds)) { + // Use nodeIds from inbound if dbInbound is not provided + nodeIdsToSet = inbound.nodeIds.map(id => typeof id === 'string' ? parseInt(id, 10) : id).filter(id => !isNaN(id) && id > 0); + } + + // Set nodeIds directly first + this.inbound.nodeIds = nodeIdsToSet; + this.confirm = confirm; this.visible = true; this.isEdit = isEdit; + + // Ensure Vue reactivity - inModal is in Vue's data, so we can use $set on inModal.inbound + if (inboundModalVueInstance && inboundModalVueInstance.$set) { + // Use $set to ensure Vue tracks nodeIds property on the inbound object + inboundModalVueInstance.$set(inModal.inbound, 'nodeIds', nodeIdsToSet); + } }, close() { inModal.visible = false; @@ -102,17 +132,14 @@ get isEdit() { return inModal.isEdit; }, - get client() { - return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null; - }, get datepicker() { return app.datepicker; }, - get delayedExpireDays() { - return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0; + get multiNodeMode() { + return app && (app.multiNodeMode || (app.allSetting && app.allSetting.multiNodeMode)) || false; }, - set delayedExpireDays(days) { - this.client.expiryTime = -86400000 * days; + get availableNodes() { + return app && app.availableNodes || []; }, get externalProxy() { return this.inbound.stream.externalProxy.length > 0; diff --git a/web/html/modals/node_modal.html b/web/html/modals/node_modal.html new file mode 100644 index 00000000..fd321fd7 --- /dev/null +++ b/web/html/modals/node_modal.html @@ -0,0 +1,228 @@ +{{define "modals/nodeModal"}} + +
+ + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + +
+
+ +{{end}} diff --git a/web/html/modals/qrcode_modal.html b/web/html/modals/qrcode_modal.html index cdbb585b..2da0e8fc 100644 --- a/web/html/modals/qrcode_modal.html +++ b/web/html/modals/qrcode_modal.html @@ -21,7 +21,7 @@ -