diff --git a/database/db.go b/database/db.go index b33a0621..f1bc99df 100644 --- a/database/db.go +++ b/database/db.go @@ -40,6 +40,11 @@ func initModels() error { &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 51203a43..92f79aa1 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -104,6 +104,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,14 +124,56 @@ type Client struct { 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") + 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 + 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 } @@ -139,4 +183,69 @@ 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/go.mod b/go.mod index 475727d9..73e56710 100644 --- a/go.mod +++ b/go.mod @@ -36,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 @@ -74,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 @@ -90,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 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 4ab3b4cf..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" @@ -209,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/api/server.go b/node/api/server.go index 11fa93c4..3cd189ea 100644 --- a/node/api/server.go +++ b/node/api/server.go @@ -6,9 +6,12 @@ import ( "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" ) @@ -40,6 +43,9 @@ func (s *Server) Start() error { // 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") { @@ -48,6 +54,8 @@ func (s *Server) Start() error { 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{ @@ -72,8 +80,8 @@ func (s *Server) Stop() error { // authMiddleware validates API key from Authorization header. func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - // Skip auth for health endpoint - if c.Request.URL.Path == "/health" { + // Skip auth for health and registration endpoints + if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/api/v1/register" { c.Next() return } @@ -117,11 +125,25 @@ func (s *Server) applyConfig(c *gin.Context) { return } - // Validate JSON - var configJSON json.RawMessage - if err := json.Unmarshal(body, &configJSON); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) - return + // 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 { @@ -175,3 +197,107 @@ func (s *Server) stats(c *gin.Context) { 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 index d72d6407..72459803 100644 --- a/node/docker-compose.yml +++ b/node/docker-compose.yml @@ -7,7 +7,8 @@ services: restart: unless-stopped environment: # - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} - - NODE_API_KEY=test-key + #- NODE_API_KEY=test-key + - PANEL_URL=http://192.168.0.7:2054 ports: - "8080:8080" - "44000:44000" @@ -18,7 +19,46 @@ services: # 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 index 617981f2..d64bef8e 100644 --- a/node/main.go +++ b/node/main.go @@ -4,6 +4,7 @@ package main import ( "flag" + "fmt" "log" "os" "os/signal" @@ -11,27 +12,88 @@ import ( "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 (required)") + flag.StringVar(&apiKey, "api-key", "", "API key for authentication (optional, can be set via registration)") flag.Parse() - // Check environment variable if flag is not provided + logger.InitLogger(logging.INFO) + + // Initialize node configuration system + // Try to find config directory (same as XRAY config) + configDirs := []string{"bin", "config", ".", "/app/bin", "/app/config"} + var configDir string + for _, dir := range configDirs { + if _, err := os.Stat(dir); err == nil { + configDir = dir + break + } + } + if configDir == "" { + configDir = "." // Fallback + } + + if err := nodeConfig.InitConfig(configDir); err != nil { + log.Fatalf("Failed to initialize node config: %v", err) + } + + // Get API key from (in order of priority): + // 1. Command line flag + // 2. Environment variable (for backward compatibility) + // 3. Saved config file (from registration) if apiKey == "" { apiKey = os.Getenv("NODE_API_KEY") } - if apiKey == "" { - log.Fatal("API key is required. Set NODE_API_KEY environment variable or use -api-key flag") + // Try to load from saved config + savedConfig := nodeConfig.GetConfig() + if savedConfig.ApiKey != "" { + apiKey = savedConfig.ApiKey + log.Printf("Using API key from saved configuration") + } } - logger.InitLogger(logging.INFO) + // If still no API key, node can start but will need registration + if apiKey == "" { + log.Printf("WARNING: No API key found. Node will need to be registered via /api/v1/register endpoint") + log.Printf("You can set NODE_API_KEY environment variable or use -api-key flag for immediate use") + // Use a temporary key that will be replaced during registration + apiKey = "temp-unregistered" + } + + // Initialize log pusher if panel URL is configured + // Get node address from saved config or environment variable + savedConfig := nodeConfig.GetConfig() + nodeAddress := savedConfig.NodeAddress + if nodeAddress == "" { + nodeAddress = os.Getenv("NODE_ADDRESS") + } + if nodeAddress == "" { + // Default to localhost with the port (panel will match by port if address doesn't match exactly) + nodeAddress = fmt.Sprintf("http://127.0.0.1:%d", port) + } + + // Get panel URL from saved config or environment variable + panelURL := savedConfig.PanelURL + if panelURL == "" { + panelURL = os.Getenv("PANEL_URL") + } + + nodeLogs.InitLogPusher(nodeAddress) + if panelURL != "" { + nodeLogs.SetPanelURL(panelURL) + } + // Connect log pusher to logger + logger.SetLogPusher(nodeLogs.PushLog) xrayManager := xray.NewManager() server := api.NewServer(port, apiKey, xrayManager) diff --git a/node/xray/manager.go b/node/xray/manager.go index a8522275..c8ced5f3 100644 --- a/node/xray/manager.go +++ b/node/xray/manager.go @@ -2,6 +2,7 @@ package xray import ( + "bufio" "encoding/json" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "time" @@ -469,3 +471,71 @@ func (m *Manager) GetStats(reset bool) (*NodeStats, error) { 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 a219dd63..2ddf1fe4 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -41,8 +41,10 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) - // Initialize NodeService for multi-node support + // 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, @@ -73,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 { @@ -130,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))) @@ -144,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 ab746a26..a878a2ca 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -29,6 +29,9 @@ type SubService struct { 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. @@ -40,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 @@ -59,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 { @@ -75,21 +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) - // 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) - } + + 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) } - ct := s.getClientTraffics(inbound.ClientStats, client.Email) - clientTraffics = append(clientTraffics, ct) - if ct.LastOnline > lastOnline { - lastOnline = ct.LastOnline + } + // 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 + } } } } @@ -120,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 @@ -183,13 +339,44 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string { return "" } -func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { - if inbound.Protocol != model.VMESS { - 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 } - // Get all nodes for this inbound - var nodeAddresses []string + // 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) @@ -198,22 +385,33 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, node := range nodes { nodeAddr := s.extractNodeHost(node.Address) if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) + nodeAddresses = append(nodeAddresses, AddressPort{Address: nodeAddr, Port: 0}) } } } } // Fallback to default logic if no nodes found - var defaultAddress string 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 = []string{defaultAddress} + nodeAddresses = []AddressPort{{Address: defaultAddress, Port: 0}} } + + return nodeAddresses +} + +func (s *SubService) genVmessLink(inbound *model.Inbound, email string) 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", @@ -351,12 +549,16 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { + for _, addrPort := range nodeAddresses { obj := make(map[string]any) for k, v := range baseObj { obj[k] = v } - obj["add"] = nodeAddr + 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, "") if linkIndex > 0 { @@ -370,37 +572,385 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { return links } +// 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 all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // 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) @@ -595,8 +1145,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port) + 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() @@ -615,37 +1170,215 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { return strings.Join(links, "\n") } +// 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 all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // 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) @@ -827,8 +1560,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port) + 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() @@ -851,37 +1589,186 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string return links } +// 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 all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // 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) @@ -1034,8 +1921,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port) + 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() @@ -1386,3 +2278,87 @@ func (s *SubService) extractNodeHost(nodeAddress string) string { } 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 6b312c03..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}.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}} +: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/inbound.js b/web/assets/js/model/inbound.js index 9aa05ed3..16ee5d34 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1902,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; } @@ -2018,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 = [], @@ -2208,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; @@ -2373,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/setting.js b/web/assets/js/model/setting.js index 3446832d..fbf1233b 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -74,6 +74,12 @@ class AllSetting { // 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 @@ -90,6 +96,18 @@ class AllSetting { } 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/node.go b/web/controller/node.go index bd3ca595..895c9b6f 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -2,11 +2,15 @@ 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" ) @@ -37,6 +41,9 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) { 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. @@ -80,7 +87,7 @@ func (a *NodeController) getNode(c *gin.Context) { jsonObj(c, node, nil) } -// addNode creates a new node. +// addNode creates a new node and registers it with a generated API key. func (a *NodeController) addNode(c *gin.Context) { node := &model.Node{} err := c.ShouldBind(node) @@ -90,31 +97,42 @@ func (a *NodeController) addNode(c *gin.Context) { } // Log received data for debugging - logger.Debugf("Adding node: name=%s, address=%s, apiKey=%s", node.Name, node.Address, node.ApiKey) + logger.Debugf("[Node: %s] Adding node: address=%s", node.Name, node.Address) - // Validate API key before saving - err = a.nodeService.ValidateApiKey(node) + // 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("API key validation failed for node %s: %v", node.Address, err) - jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err) + 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", err) + jsonMsg(c, "Failed to add node to database", err) return } // Check health immediately go a.nodeService.CheckNodeHealth(node) - jsonMsgObj(c, "Node added successfully", node, nil) + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + + logger.Infof("[Node: %s] Node added and registered successfully", node.Name) + jsonMsgObj(c, "Node added and registered successfully", node, nil) } // updateNode updates an existing node. @@ -150,6 +168,19 @@ func (a *NodeController) updateNode(c *gin.Context) { 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) @@ -163,6 +194,15 @@ func (a *NodeController) updateNode(c *gin.Context) { 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 @@ -189,6 +229,9 @@ func (a *NodeController) updateNode(c *gin.Context) { return } + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + jsonMsgObj(c, "Node updated successfully", node, nil) } @@ -206,6 +249,9 @@ func (a *NodeController) deleteNode(c *gin.Context) { return } + // Broadcast nodes update via WebSocket + a.broadcastNodesUpdate() + jsonMsg(c, "Node deleted successfully", nil) } @@ -229,12 +275,20 @@ func (a *NodeController) checkNode(c *gin.Context) { 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) } @@ -295,3 +349,213 @@ func (a *NodeController) reloadAllNodes(c *gin.Context) { 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 f11a0422..137687eb 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -30,10 +30,17 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { 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. @@ -60,3 +67,13 @@ func (a *XUIController) xraySettings(c *gin.Context) { 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 030da972..31eb3aeb 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -101,6 +101,12 @@ type AllSetting struct { // 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 } @@ -171,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 @@ - - - - - - - -